In the previous parts, we built some of the data structures we needed for
our Rust ray tracer, as well as a parser that can convert a simple .obj
file into a vector of Triangle structs for our calculations. Now we can
get into the thick of ray tracing with our Triangles!
Setting The Scene
Recall in our main function, we’ve just extracted a Vec<Triangle> named
triangles from a .obj input:
We can use a constructor function to create our Scene struct from
triangles, like so:
Of course, we also need to define a default Camera to initialize.
We’ll bundle it with a bunch of constants for producing the image
we want.
We update main to initialize our Scene.
Ray Tracing, At Last
Pretty Colors!
We’ll package a pixel into a neat little Color struct.
As is standard in computer graphics, we will represent
colors in RGB, with red/green/blue values from 0 to 255.
Naturally, this fits into an 8-bit unsigned integer,
so we will define Color as such:
Here, a value of 0 red, 0 green, and 0 blue represents black,
while 255 red, 255 green, and 255 blue represents white.
255 red, 0 green, and 0 blue would represent a perfectly red pixel.
High School Geometry
We can easily use our Vector structs to represent our rays, with
the camera’s origin serving as an implicit origin for each ray.
However, we first need to use some trusty trigonometry
so that we can rotate these vectors about in 3D space.
Here are some useful vector operations that we can implement
right now:
Ray Projection
We will implement a method on Scene called iterate_over_rays
that will trace our rays (finally!)
and return a list of Colored pixels to render in our output image.
First we define some useful aliases (these will be optimized away
by the compiler, so no worries about efficiency):
A natural way to iterate over our pixels is by using a for loop.
Recall the algorithm we are attempting to implement:
Draw a light ray from the Camera through
each pixel-corresponding region of our ViewPlane
For every surface (Triangle), determine if it intersects our ray
At the closest intersection between this ray and
a surface, calculate the brightness of said surface
Brightness grows with how perpendicular the
surface is to light source
Repeat for every pixel
In Rust, a corresponding for loop might look like:
Our goal is to project rays out from the camera origin through
specific points on the view plane. This is best illustrated with
a diagram:
Rays projected out from a camera onto a view plane. Source.
We can approximate this by defining an x/y pixel offset from
the camera origin, and then rotating the resulting ray
by the camera’s direction.
(Note that this method is not wholly accurate and leads to
camera distortion with highly angled camera views; a perfect
rendering would rely on a transformation of the view plane).
Triangle Intersections
Now that we’re able to calculate the initial rays of light being
projected out from the camera, how can we tell if one of these rays will
hit a triangle?
First, we need to figure out where the ray intersects the plane that
the triangle lies on. Let’s define a plane struct.
Recall from geometry that a plane in 3D space is basically the
set of all coordinates that satisfy the equation
Ax + By + Cz + K = 0, where <x,y,z> is a coordinate:
We must also consider the nature of the rays we defined
above. We have m, the direction of the ray,
an origin point s (which we implicitly define as the
camera’s origin point), and λ, a factor of how far from the
origin we are. Thus we arrive at the equation P = λm + s,
where P := <x,y,z> is any point that satisfies a point on the ray.
We additionally
require that λ > 0, otherwise the ray would include points
behind its origin.
Now we can solve the system of equations for the plane and the ray,
giving us λ = -(<A,B,C>⋅s + k) / (<A,B,C>⋅m) for our
intersection point.
Finally, with λm + s, we calculate the exact coordinates of our intersection. In Rust, this is implemented as:
Now back to our ray tracing loop. We define a closure for extracting
intersection info between an input plane p and a given ray
projected from the camera.
Now we can define a helper function that will get the index of the closest triangle to be intersected, if any.
First we have to update Scene to include new information:
we precalculate the planes that each triangle lies in, to save
computation time for each of the rays.
We additionally define a new method on Triangle
to calculate the plane on which it lies.
Now for the helper function. Let’s implement it functionally,
with map/reduce-like syntax.
The process is simple:
We take every plane associated with the
triangles and list their indices
We map them into their intersection point and index
We additionally calculate distance from the camera origin
If the intersection point doesn’t exist, we return f64::INFINITY
as the distance
We reduce the stream to the closest intersection point; if it’s infinity
we return None.
The code requires us to define three additional closures
that can be mapped onto our functional stream.
They are as follows:
Note that we use distance to flag null intersections. Thus,
dist_from_camera is also responsible for detecting an
intersection with a triangle. We do this with an as yet
undefined method on Vector: slow_intersect_check.
This function is named as such due to its speed; it’s
quite slow and inefficient to perform in bulk, and in
later parts we will explore how to optimize it.
But for now, let’s implement it.
With this, we verify that an intersection point lies
on the same side of a triangle’s side as the triangle’s
third vertex. By verifying that a point lies on the same
side for all three sides, we essentially confirm that the point
lies somewhere inside the triangle. If we assume that the point
is already an intersection with the triangle’s plane, we know that
we’ve found a point directly on the triangle!
We finally can add our triangle
intersection detector to iterate_over_rays:
We update our main function accordingly to reflect
our ray tracing method.
Huzzah! We now have a super basic ray tracer!
Conclusion
Well that was a long read, and we’ve barely gotten started!
We managed to build an algorithm that can project
rays from the camera and detect if those rays intersect a triangle
in our scene. We additionally laid some groundwork for some
more advanced rendering. But for now, the image that our
program will attempt to render has only two colors, one
for an object, and one for empty space. Not much to write home about!
Stay tuned, for in the next part we’ll learn how to render
our output as .ppm files, and use some nifty utilities
to convert them into familiar formats that we know and love,
such as .pngs or .jpgs!
Additionally, we will learn how to calculate lighting and (gasp)
even shadows! And we will also cover some basic algorithmic
optimizations that will greatly speed up our algorithm’s speed.
In the end, we’ll use the power of Rust’s compile-time safety
guarantees to create possibly the easiest parallel ray tracing
program you’ve ever seen!