This repository hosts my take on Ray Tracing in One Weekend Book Series using Scala and functional programming.
The goal behind this project is really just:
- Further develop my Scala and Functional Programming skills
- Try out Scala tools, such as:
- Have some fun with basic Computer Graphics
This document is divided in 3 sections (one for each book of the series). In each section you'll find the generated image after each new feature implementation as well as a brief explanation of my implementation.
⚠️ Code snippets on this README are simplified for readibility reasons⚠️
Book 1: Ray Tracing in One Weekend
Everytime you need to create a file and write some content to it, you always end up Googling how to do it. In an effort to save you some time, take a look at How to write to a File in Scala and the PPM file format specification
In a nutshell the PPM file specification consists of a grid of colors. The colors can be represented in RGB or any other format, but let's keep in simple with RGB (0-255) Take a look at the following example from Wikipedia:
P3
# "P3" means this is a RGB color image in ASCII
# "3 2" is the width and height of the image in pixels
# "255" is the maximum value for each color
# This, up through the "255" line below are the header.
# Everything after that is the image data: RGB triplets.
# In order: red, green, blue, yellow, white, and black.
3 2
255
255 0 0 0 255 0 0 0 255
255 255 0 255 255 255 0 0 0
which will show up as:
We'll be using Scala's case class
, object
and enum
, just because they seem to be better suited for FP data modeling
Defining a Color
is pretty straightforward, and you can use a companion object
to declare static like functions and constants.
case class Color (red: Int, green: Int, blue: Int)
object Color:
val BYTE_SIZE = 255
def white: Color = Color(255, 255, 255)
def black: Color = Color(0, 0, 0)
end Color
we can then override the toString
method to make the code easier to read:
override def toString: String = s"$red $green $blue"
We'll need a data structure to model an image, something like this should do the trick:
case class Image(width: Int, height: Int, content: Seq[Seq[Color]] = Seq.empty)
We can then declare some methods to write the Image
's content to a file:
def rowToString(row: Seq[Color]): String = row
.foldLeft("")((accRow, byte) => s"$accRow$byte ")
.trim
def write(writer: PrintWriter): Unit =
// header
writer.write(s"P3\n$width $height\n${Color.BYTE_SIZE}\n")
// content
content.foreach(writer.write(s"${rowToString(_)}\n")
Just for fun, let's create a rainbow effect given the Image's height and width.
This is an excellent case to use Seq.tabulate
.
def fillRainbow(): Image = copy(
content = Seq.tabulate[Color](this.height, this.width)((h: Int, w: Int) =>
val r = w.toDouble / (this.width - 1)
val g = (this.height - 1 - h).toDouble / (this.width - 1)
val b = 0.25
Color.fromRatio(r, g, b)
)
)
This part is very similar to the original approach in C++, just take a look at the Vec3.scala
file, and you'll
quickly grasp this data structure.
However, unlike the original approach Vec3
is not used for colors, just 3-dimensional vectors and coordinates.
Declaring a ray function (
-
$A$ is the ray's origin -
$b$ is the ray's direction
is as easy as:
case class Ray(origin: Vec3, direction: Vec3):
def at(t: Double): Vec3 = origin + t * direction
Now we just need to calculate the linear interpolation between white and blue given the ray's direction y-axis value
(which will be a value between -1 and 1). Take a look at Color.lerpStart
to better understand the implementation details,
but in a nutshell it's just an implementation of the following formula:
where:
-
$t$ is a value between 0 and 1
def rayColor(ray: Ray): Color =
Color.white.lerpStart(0.5 * (ray.direction.unit.y + 1.0), Color.skyBlue)
Let's create a Sphere
case class to encapsulate its logic in one place. If you're not like me and remember your geometry
classes, you know that a sphere's equation is something like:
where:
-
$r$ = sphere radius -
$(c_x, c_y, c_z)$ = is the 3-dimensional coordinate in the center of the Sphere
This means that if:
-
$(x - c_x)^2 + (z - c_z)^2 + (z - c_z)^2 > r^2$ then$(x, y, z)$ is outside the sphere; -
$(x - c_x)^2 + (z - c_z)^2 + (z - c_z)^2 < r^2$ then$(x, y, z)$ is inside the sphere;
With this in mind, I recommend you go over the book's explanation on Ray-Sphere Intersection and to better understand the math behind the actual implementation.
case class Sphere(center: Vec3 = Vec3.zero, radius: Double = 1):
def hit(ray: Ray): Boolean =
val origin = ray.origin - center
val a = ray.direction.dot(ray.direction)
val b = 2.0 * origin.dot(ray.direction)
val c = origin.dot(origin) - Math.pow(radius, 2)
val discriminant = Math.pow(b, 2) - 4 * a * c
!(discriminant < 0)
Now we need to update the rayColor
function to render a red colored pixel when a Sphere is hit. Let's test this out
by hardcoding a Sphere in the middle of the Scene:
def rayColor(ray: Ray): Color =
if (Sphere(Vec3(0, 0, -1), 0.5).hit(ray)) then
Color.red
else
Color.white.lerpStart(0.5 * (ray.direction.unit.y + 1.0), Color.skyBlue)
And we'll get an Image which looks like this:
Take a look at Shading with Surface Normals section for a theoretical background on how shading is done.
What we have to understand is that not only do we want to know if a ray hit the Sphere, but we also need to grab:
-
$t$ : how far away from the ray's origin was the hit -
$p$ : the hit coordinates -
$n$ : the surface normal - frontFacing: was the Sphere hit from the inside or the outside
Let's create a data structure to model our hit results. As I see it, we have 2 options:
- use an
enum
which has 2 entriesHit
andNoHit
, where the first one will contain the values we need. - Or use a simple case class, which alongside the
Option
class can haveSome
orNone
value for hit or no hit respectively.
In the end, I've decided to implement option 2.
case class HitResult(
point: Vec3,
normal: Vec3,
t: Double,
frontFacing: Boolean = true,
)
we can now refactor our Sphere.hit
method to this include this data structure and well as simplify it:
def hit(ray: Ray, tMin: Double, tMax: Double): Option[HitResult] =
val origin = ray.origin - center
// same as ray.direction.dot(ray.direction)
val a = ray.direction.lengthSquared
// we can just use b / 2
val halfB = origin.dot(ray.direction)
val c = origin.lengthSquared - Math.pow(radius, 2)
val discriminant = Math.pow(halfB, 2) - a * c
if discriminant < 0 then return None
val sqrtDiscriminant = Math.sqrt(discriminant)
val t = Seq(
(-halfB - sqrtDiscriminant) / a,
(-halfB + sqrtDiscriminant) / a,
).find(x => !(x < tMin || x > tMax))
if t.isEmpty then return None
val point = ray.at(t.get)
Option(
HitResult(
point = point,
normal = (point - center) / radius,
t = t.get,
)
.setFaceNormal(ray)
)
and update our rayColor
function to:
def rayColor(ray: Ray): Color = Sphere(Vec3(0, 0, -1), 0.5).hit(ray, 0, Double.MaxValue) match
case None => Color.white.lerpStart(0.5 * (ray.direction.unit.y + 1.0), Color.skyBlue)
case Some(hit) => 0.5 * (Color.fromRatio(1, 1, 1) + Color.fromRatio(hit.normal.x, hit.normal.y, hit.normal.z))
Now we can create a Scene
case class which will take care of storing our Props (such as our Spheres) as well as
casting and tracing rays.
case class Scene(props: Seq[Prop]):
def propHits(ray: Ray, tMin: Double, tMax: Double): Option[HitResult] =
props
.map(_.hit(ray, tMin, tMax)) // calculate hits
.collect({ case Some(h) => h }) // filter by defined values
.sortWith(_.t < _.t) // order by closest prop to origin/camera
.headOption // get closest option if available
def rayColor(ray: Ray): Color = this.propHits(ray, 0, Double.MaxValue) match
case None => Color.white.lerpStart(0.5 * (ray.direction.unit.y + 1.0), Color.skyBlue)
case Some(hit) => 0.5 * (Color.fromRatio(1, 1, 1) + Color.fromRatio(hit.normal.x, hit.normal.y, hit.normal.z))
propHits
: this method takes a ray and the min and max distance the origin to render. It hits all props, orders by the closest prop hit and then fetches the firstHit
. If suchHitResult
does not exist, it returnsNoHit
;rayColor
: callspropHits
for a single ray and returns the correct pixel color;
Now, you can create a scene with multiple Spheres, and the Image will be rendered correctly, i.e. with the closest Sphere visible.
Take a look at the book's section on Antialiasing to better understand how it works, but essentially we need to calculate the color of random viewport ray hits inside the pixel we're working on, and then merge these values together to get a blending color effect.
Let's move the render logic from our main.scala
file into a dedicated file. Instead of outputting an Image, we just
want it to output a Color matrix of a specified width
and height
. Breaking down the render logic for each color pixel:
- Create a sequence of
SAMPLES_PER_PIXEL
random values between 0 and 1 - Map those random values into the calculated with
u
andv
- Reduce that sequence into a unique Color which contains the sum of all RGB color values
- Divide that color by
SAMPLES_PER_PIXEL
which calculates the "average" color in the pixel
While we're at it, let's place the rayColor
method here as well:
case class Renderer(viewport: Viewport, scene: Scene):
private val SAMPLES_PER_PIXEL = 100
def renderContent(width: Int, height: Int): Seq[Seq[Color]] =
Seq.tabulate[Color](height, width)((h, w) =>
Seq
.fill(SAMPLES_PER_PIXEL)(Random.nextDouble()) // step 1
.map { random => // step 2
val u = (w.toDouble + random) / (width - 1)
val v = (height - 1 - h + random) / (height - 1)
rayColor(viewport.getRay(u, v))
}
.reduce(_ + _) // step 3
/ // step 4
SAMPLES_PER_PIXEL,
)
def rayColor(ray: Ray): Color = scene.propHits(ray, 0, Double.MaxValue) match
case None => Color.white.lerpStart(0.5 * (ray.direction.unit.y + 1.0), Color.skyBlue)
case Some(hit) => 0.5 * (Color.fromRatio(1, 1, 1) + Color.fromRatio(hit.normal.x, hit.normal.y, hit.normal.z))
We'll then update our main function to:
@main
def main(): Unit = {
val aspectRatio = 16.0 / 9.0
val width = 400
val height = (400 / aspectRatio).toInt
val viewport = Viewport()
val scene = Scene(
Seq(
Sphere(Vec3(0, -100.5, -1), 100),
Sphere(Vec3(0, 0, -1), 0.5),
),
)
val content = Renderer(viewport, scene).renderContent(width, height)
Image(width, height)
.fillContent(content)
.write(
PrintWriter(File("output.ppm")),
)
}
Picking random points in a unit sphere:
object Vec3:
// ...
def random: Vec3 = Vec3(Random.nextDouble(), Random.nextDouble(), Random.nextDouble())
def random(min: Double, max: Double): Vec3 = Vec3(
Utility.randomDoubleBetween(min, max),
Utility.randomDoubleBetween(min, max),
Utility.randomDoubleBetween(min, max),
)
@tailrec
def randomInUnitSphere: Vec3 =
val hypothesis = Vec3.random(-1, 1)
if hypothesis.lengthSquared < 1 then hypothesis else Vec3.randomInUnitSphere
end Vec3
And updating the rayColor
method:
case class Renderer(/* ... */):
private val MAX_RAY_BOUNCE = 50
def rayColor(ray: Ray, depth: Int = MAX_RAY_BOUNCE): Color =
if (depth <= 0) then return Color.black
scene.propHits(ray, 0, Double.MaxValue) match
case None => Color.white.lerpStart(0.5 * (ray.direction.unit.y + 1.0), Color.skyBlue)
case Some(hit) =>
val target = hit.point + hit.normal + Vec3.randomInUnitSphere
0.5 * rayColor(Ray(hit.point, target - hit.point), depth - 1)
Which will render:
As we can see (or not), the scene is a little dark. As the book explains, we need to do some gamma correction:
case class Renderer(/* ... */):
private val GAMMA = 2d
def renderContent(width: Int, height: Int): Seq[Seq[Color]] =
val totalPixels = width * height
Seq.tabulate[Color](height, width)((h, w) =>
val progress = (h * width + (w + 1)) / totalPixels.toDouble * 100d
Seq
.fill(SAMPLES_PER_PIXEL)(Random.nextDouble())
.map { random =>
val u = (w.toDouble + random) / (width - 1)
val v = (height - 1 - h + random) / (height - 1)
rayColor(viewport.getRay(u, v))
}
.reduce(_ + _)
.pow(1 / GAMMA), // <--- add this here
)
and also add the pow
method to our Color
case class:
case class Color(/* ... */):
// ...
def pow(operand: Double): Color = Color(
Math.pow(red, operand).toInt,
Math.pow(green, operand).toInt,
Math.pow(blue, operand).toInt,
)
Fixing shadow acne is as easy as:
case class Renderer(/* ... */):
/** To fix shadow acne this value cannot be 0
*/
private val T_MIN = 0.001
// ...
def rayColor(ray: Ray, depth: Int = MAX_RAY_BOUNCE): Color =
// ...
scene.propHits(ray, T_MIN, Double.MaxValue) match // <--- replace 0 with T_MIN
// ...
To have a true Lambertian distribution and an Alternative diffuse formaluation, we simply need to add options 2 and 3 to
the Vec3
object. And of course, update the rayColor
method and use any of the 3 options to calculate the target
.
object Vec3:
// ...
/** Option 1: Good enough Lambertian Reflection
*/
@tailrec
def randomInUnitSphere: Vec3 =
val hypothesis = Vec3.random(-1, 1)
if hypothesis.lengthSquared < 1 then hypothesis else Vec3.randomInUnitSphere
/** Option 2: True Lambertian Reflection
*/
def randomInUnitSphereUnit: Vec3 = randomInUnitSphere.unit
/** Option 3: An alternative diffuse formulation
*/
def randomInUnitSphereHemisphere(normal: Vec3): Vec3 =
val n = Vec3.randomInUnitSphere
if n.dot(normal) > 0d then n else -n
end Vec3
Let's create a trait
for Material
, which will be extended by a Lambertian
and Metal
case class.
Lambertian
is our current implementationMetal
will require a new implementation for reflective materials
trait Material(color: Color):
def scatter(ray: Ray, hit: HitResult): Option[ScatterResult]
end Material
We'll also need a ScatterResult
data structure which will contain the scattered ray and its attenuation.
case class ScatterResult(scattered: Ray, attenuation: Color)
Now, we need to add a Material
type attribute to the HitResult
case class, as well as out Prop
trait.
So, our current implementation of Lambertian
material will look something like:
case class Lambertian(albedo: Color) extends Material(albedo):
def scatter(ray: Ray, hit: HitResult): Option[ScatterResult] =
val scatterDirection = hit.normal + Vec3.randomInUnitSphereUnit
val finalScatterDirection = if scatterDirection.isNearZero then hit.normal else scatterDirection
Option(ScatterResult(Ray(hit.point, finalScatterDirection), albedo))
end Lambertian
And our Metal
material with fuzziness will be something like:
case class Metal(albedo: Color, fuzz: Double = 1) extends Material(albedo):
private val fuzziness = Utility.clamp(fuzz, 0d, 1d)
def scatter(ray: Ray, hit: HitResult): Option[ScatterResult] =
val scatterReflection = ray.direction.reflect(hit.normal).unit
val scattered = Ray(hit.point, scatterReflection + fuzziness * Vec3.randomInUnitSphere)
if scattered.direction.dot(hit.normal) > 0 then Option(ScatterResult(scattered, albedo))
else None
end Metal
Creating the refract
function:
def refract(normal: Vec3, refractionRatio: Double): Vec3 =
val cosTheta = Math.min((-this).dot(normal), 1d)
val perpendicular = refractionRatio * (this + cosTheta * normal)
val parallel = -Math.sqrt(Math.abs(1d - perpendicular.lengthSquared)) * normal
perpendicular + parallel
and the Dialetric material:
case class Dielectric(refractionIndex: Double) extends Material(Color.white):
def scatter(ray: Ray, hit: HitResult): Option[ScatterResult] =
val refractionRatio = if hit.frontFacing then (1d / refractionIndex) else refractionIndex
val unitDirection = ray.direction.unit
val refracted = unitDirection.refract(hit.normal, refractionRatio)
Option(ScatterResult(Ray(hit.point, refracted), Color.white))
end Dielectric
and then updating the scene with this new material:
Determining if the Ray can refract:
case class Dielectric(refractionIndex: Double) extends Material(Color.white):
def scatter(ray: Ray, hit: HitResult): Option[ScatterResult] =
val refractionRatio = if hit.frontFacing then (1d / refractionIndex) else refractionIndex
val unitDirection = ray.direction.unit
val cosTheta = Math.min((-unitDirection).dot(hit.normal), 1d)
val sinTheta = Math.sqrt(1d - cosTheta * cosTheta);
val canRefract = refractionRatio * sinTheta <= 1.0
val refracted =
if canRefract then unitDirection.refract(hit.normal, refractionRatio)
else unitDirection.reflect(hit.normal)
Option(ScatterResult(Ray(hit.point, refracted), Color.white))
end Dielectric
Then adding Schlick's approximation:
private def reflectance(cos: Double, refIdx: Double): Double =
val r0 = Math.pow((1 - refIdx) / (1 + refIdx), 2)
r0 + (1 - r0) * Math.pow((1 - cos), 5);
Finally modeling a hallow glass sphere:
We simply need to add some new properties and calculations to the Viewport
case class:
case class Viewport(
lookFrom: Vec3,
lookAt: Vec3,
vup: Vec3,
/** Vertical field-of-view in degrees
*/
verticalFOV: Double = 90d,
aspectRatio: Double = (16d / 9d),
):
private val height: Double = Math.tan(verticalFOV.toRadians / 2d) * 2d
private val width: Double = height * aspectRatio
private val w = (lookFrom - lookAt).unit
private val u = vup.cross(w).unit
private val v = w.cross(u)
private val focalLength: Double = 1d
val origin: Vec3 = lookFrom
val horizontal: Vec3 = width * u
val vertical: Vec3 = height * v
val lowerLeftCorner: Vec3 =
origin - horizontal / 2 - vertical / 2 - w
def getRay(s: Double, t: Double): Ray =
Ray(origin, lowerLeftCorner + s * horizontal + t * vertical - origin)
WIP
object Scene:
def randomScene: Scene =
val groundMaterial = Lambertian(Color.fromRatio(0.5, 0.5, 0.5))
val ground = Sphere(Vec3(0, -1_000, 0), 1_000, groundMaterial)
val smallSpheres = Seq
.tabulate[Sphere](20, 20)((a, b) =>
val x = a - 11
val y = b - 11
val randomDouble = Random.nextDouble()
val center = Vec3(x + 0.9 * Random.nextDouble(), 0.2, y + 0.9 * Random.nextDouble())
if randomDouble < 0.8 then
// diffuse
Sphere(center, 0.2, Lambertian(Color.random))
else if randomDouble < 0.95 then
// Metal
Sphere(center, 0.2, Metal(Color.random(0.5, 1d), Utility.randomDoubleBetween(0, 0.5)))
else
// Glass
Sphere(center, 0.2, Dielectric(1.5)),
)
.flatten
.filter(s => (s.center - Vec3(4, 0.2, 2)).length > 0.9)
val leftSphere = Sphere(Vec3(-4, 1, 0), 1d, Lambertian(Color.fromRatio(0.4, 0.2, 0.1)))
val middleSphere = Sphere(Vec3(0, 1, 0), 1d, Dielectric(1.5))
val rightSphere = Sphere(Vec3(4, 1, 0), 1d, Metal(Color.fromRatio(0.7, 0.6, 0.5), 0.0))
Scene()
.addProps(smallSpheres)
.addProp(ground)
.addProp(leftSphere)
.addProp(middleSphere)
.addProp(rightSphere)
end Scene
Running with a higher resolution and antialiasing effects (took about 65 minutes to render)
Book 2: Ray Tracing The Next Week
WIP
WIP