A port of the book Ray Tracing in One Weekend by Peter Shirley to the Ruby programming language.
As far as I know there are two ways one could programmatically generate an image. By having the program, while it is running, produce a window and, in it, the image. Or, by having the program produce the image and save it in a image file (JPEG, PNG, TIFF, etc…), which can then be opened by an image viewer. This experiment deals with the latter.
I’ll be following the book Ray Tracing in One Weekend, it describes the processes of writing a Ray Tracer using C++. I am not a C++ programmer though, and do not think that simply rewriting code shown in a book as being a very effective learning methodology. Therefore I’ll try to re-implement this Ray Tracer using Ruby.
There are many image formats available, most of them are very complex, PPM will be the format used throughout this experiment. Maybe I’ll change it later.
The code bellow (PPM image file) would produce a 3x2 pixel image of red, green, blue, yellow, white, and black colors. I commented a few lines in the snippet bellow to describe the PPM image format.
P3 # Set colors to ASCII 3 2 # Set file to 3 columns and 2 rows 255 # Set max color to 255 # Image content 255 0 0 0 255 0 0 0 255 255 255 0 255 255 255 0 0 0
Let’s begin with a simple image gradient. First we set the image size, then we store both dimensions as two arrays that will be iterated on later.
image_width = 256
image_height = 256
hor = (0..(image_width - 1)).to_a
ver = hor.reverse
$stdout
is our door to the outside world, and whatever gets out of this file I’ll then pipe to a new file. And again, the snippet bellow sets up our image file as per PPM requirements.
$stdout << "P3\n" << image_width << " " << image_height << "\n255\n"
And now for the fun part. Think of this as we were creating a 2D array of 3-tuple. Line by line, and pixel by pixel. Each pixel is described by 3 integers, each of those is a RGB value. Notice that we first get a floating number between 0.0 and 1.0, that has to be normalized back to an integer between 0 and 255. And lastly $stdout
spits out our 3 values, go down a line, and iterate again.
ver.each do |ypx|
hor.each do |xpx|
r = xpx.to_f / (image_width - 1)
g = ypx.to_f / (image_height - 1)
b = 0.25
ir = (r * 255.999).to_i
ig = (g * 255.999).to_i
ib = (b * 255.999).to_i
$stdout << ir << " " << ig << " " << ib << "\n"
end
end
The creating-image-file part gets dealt by the command line. At least for now.
$ ruby raytracer.rb > gradient.ppm
And since most places don’t know what to do with an PPM image file, we can must also convert it to whatever file format you prefer. I’m converting it to JPEG using ImageMagick.
$ convert gradient.ppm gradient.jpg
This section of the book shows the creation of a vec3
class that could represent a coordinate or a RGB value. I’m sticking with Ruby’s Vector
class for now. I’m using this class to implement a write_color
helper function.
require 'matrix'
def color(red, green, blue)
Vector.elements([red, green, blue])
end
def write_color(color, out = $stdout)
out << (color[0] * 255.999).to_i << " " <<
(color[1] * 255.999).to_i << " " <<
(color[2] * 255.999).to_i << "\n"
end
And now we can use write_color
inside our Ray Tracer.
require_relative 'color'
image_width = 256
image_height = 256
hor = (0..(image_width - 1)).to_a
ver = hor.reverse
$stdout << "P3\n" << image_width << " " << image_height << "\n255\n"
ver.each do |ypx|
hor.each do |xpx|
color_arr = [
(xpx.to_f / (image_width - 1)),
(ypx.to_f / (image_height - 1)),
0.25
]
pixel_color = Vector.elements(color_arr)
write_color(pixel_color)
end
end
A ray is a vector, it has an origin and a direction. It can be described by the mathematical function:
require 'matrix'
require_relative 'vector'
class Ray
attr_reader :origin, :direction
def initialize(origin, direction)
@origin = origin
@direction = direction
end
def at(time)
@origin + (time * @direction)
end
end
- Calculate the ray from the eye to the pixel_color.
- Determine which objects the ray intersects.
- Compute a color for that intersection point.
require_relative 'color'
require_relative 'ray'
require 'matrix'
def ray_color(ray)
unit_direction = unit_vector(ray.direction)
t = (0.5 * unit_direction[1]) + 1.0
((1.0 - t) * color(1.0, 1.0, 1.0)) + (t * color(0.5, 0.7, 1.0))
end
# Image
aspect_ratio = 16.0 / 9.0
image_width = 400
image_height = (image_width / aspect_ratio).to_i
vert = (0..image_height).to_a
vert.reverse!
# Camera
viewport_height = 2.0.to_i
viewport_width = (aspect_ratio * viewport_height).to_i
focal_length = 1.0
origin = Vector.elements([0, 0, 0])
vec3 = Vector.elements([0, 0, focal_length])
horizontal = Vector.elements([viewport_width, 0, 0])
vertical = Vector.elements([0, viewport_height, 0])
lower_left_corner = origin - (horizontal / 2) - (vertical / 2) - vec3
$stdout << "P3\n" << image_width << " " << image_height << "\n255\n"
vert.each do |ypx|
image_width.times do |xpx|
u = xpx.to_f / (image_width - 1)
v = ypx.to_f / (image_height - 1)
r = Ray.new(origin, lower_left_corner + (u * horizontal) + (v * vertical) - origin)
pixel_color = ray_color(r)
write_color(pixel_color)
end
end
First we define a color for a ray that hits nothing, which is the same as defining a background.
require_relative 'color'
require_relative 'ray'
require 'matrix'
def sphere_hit?(center, radius, ray)
oc = ray.origin - center
a = ray.direction.dot(ray.direction)
b = 2.0 * oc.dot(ray.direction)
c = oc.dot(oc) - (radius * radius)
discriminant = (b * b) - (4 * a * c)
discriminant.positive?
end
def ray_color(ray)
center = Vector.elements([0, 0, -1])
color(1, 0, 0) if sphere_hit?(center, 0.5, ray)
unit_direction = unit_vector(ray.direction)
t = (0.5 * unit_direction[1]) + 1.0
((1.0 - t) * color(1.0, 1.0, 1.0)) + (t * color(0.5, 0.7, 1.0))
end
# Image
aspect_ratio = 16.0 / 9.0
image_width = 400
image_height = (image_width / aspect_ratio).to_i
vert = (0..image_height).to_a
vert.reverse!
# Camera
viewport_height = 2.0.to_i
viewport_width = (aspect_ratio * viewport_height).to_i
focal_length = 1.0
origin = Vector.elements([0, 0, 0])
vec3 = Vector.elements([0, 0, focal_length])
horizontal = Vector.elements([viewport_width, 0, 0])
vertical = Vector.elements([0, viewport_height, 0])
lower_left_corner = origin - (horizontal / 2) - (vertical / 2) - vec3
$stdout << "P3\n" << image_width << " " << image_height << "\n255\n"
vert.each do |ypx|
image_width.times do |xpx|
u = xpx.to_f / (image_width - 1)
v = ypx.to_f / (image_height - 1)
r = Ray.new(origin, lower_left_corner +
(u * horizontal) + (v * vertical) -
origin)
pixel_color = ray_color(r)
write_color(pixel_color)
end
end