Today we'll see our first approach for assigning pixel colors called ray tracing. We'll focus on rendering simple scenes that contain spheres and a handful of triangles. An application of this could be like approximating the meatballs in Cloudy with a Chance of Meatballs from the top-right image, which used a tool called Arnold to render the frames in the movie. Another important ray tracer to know about is Pixar's Renderman.
Compared to other rendering techniques we will see later on, ray tracing has the advantage that it can produce very photorealistic effects. The disadvantage, however, is that it can be much slower than other techniques. Modern GPUs, however, are making ray tracing a much more competitive choice when rendering scenes in real-time.
The main idea of ray tracing consists of sending "rays" from an observation point (e.g. a camera) through an image plane and into the (virtual) 3d world you want to visualize. The image plane is where the pixels are and we will send a ray through each pixel.
Ray tracing comes down to a few simple steps, which can be made more complicated if you want to achieve more interesting features:
That's it! The hardest part, however, comes in step 2c, when we need to figure out the color of the pixel. This is hard because it could involve generating secondary rays which get generated due to reflection or refraction (more on this later). For now we'll just assign a single color for objects in our scenes and leave shading for the next lecture. Step 3b can be hard too (and hard to make efficient), but we will focus on simple geometries (planes, spheres and triangles) in our scenes.
The demo above shows an overhead view of the ray tracing process. Click on the buttons to send rays from the camera into the scene. Note that each ray originates from the camera and it's direction is determined by the pixel it passes through. If a ray intersects one of the circles (on overhead view of a sphere), we store the closest intersection point. We can then determine the color of the pixel the ray passes through by analyzing the lighting in the scene (the mini yellow circle is a light), or by sending secondary rays that reflect off the circle, or refract through it. Here, we just color the pixel the same color as the intersected circle (if any), otherwise, we retain the background color (white in this case).
To define rays, we first need to set up a camera to look at our scene. If you were taking a picture with a real camera, what are some of the parameters that define the view? For one, there is the position of the camera, so we need to specify the camera position, which we will denote as
Finally, when you look through a camera, you can't see 360 degrees around you - you can only see a fraction of that. We'll call this the image plane: it's the area that you can see in front of you and we will assign colors to each pixel in the image plane. We'll assume that our camera is looking right at the center of the image plane, and that the offset from our camera to the image plane is some distance
(image adapted from here)
In physical 3d space, the image plane has width
Therefore,
Note that if we used the vertical field-of-view
$$
\tan\left(\frac{\alpha_v}{2}\right) = \frac{\frac{1}{2}h}{d},
$$
and we would have
The next thing we need to do is create rays! A ray is just a line, and lines are entirely defined by an origin (3d point) and a direction (3d vector). Our goal is to create a ray that passes through each pixel. The origin will always be the camera but the direction will depend on what kind of view you're trying to create:
left: orthographic view, right: perspective view
(images from Interactive Computer Graphics)
For a perspective view, we need to compute the 3d coordinates of each pixel in the image plane. We've talked about the width and height of the image plane, but we haven't yet talked about the other ingredient we need: the coordinate system. For an HTML
Canvas, the coordinate system starts at the top-left of the canvas. The x-direction goes from left-to-right and the y-direction goes from top-to-bottom.
For now, assume that our 3d (world) coordinate system has the origin at the eye, with the x-direction going from left-to-right and the y-direction going from bottom-to-top. Let's also assume that we are looking right at the center of the image plane. In our coordinate system, the right side of the image plane will have an x-coordinate of
So when we're processing a pixel (i, j)
from the HTML
Canvas, we need to transform it to our image plane system. For a pixel (i, j)
. We need to do a few things: (1) recenter the coordinates, (2) scale the coordinates and (3) account for the fact that the HTML
Canvas has the y-coordinates going downwards. Assuming that the pixels along the width can be indexed from
Why the 0.5? The 0.5 isn't completely necessary, but when we are sending a ray through a pixel, we are "sampling" the pixel at the center. Not having the 0.5 in the equations above would mean that you sample the pixel at the top-left corner of each pixel, which is fine. You can also generate a random number between [0, 1] to generate a random pixel sample.
Finally, for a pixel (i, j)
, it's 3d coordinates are
Therefore, assuming the camera (eye) is at the origin, our ray is described by the origin
Now we need to do step 2b in the algorithm described above: intersect our rays with objects in our scene. We will focus on simple objects like planes, sphere and triangles. Before proceeding, let's describe our rays mathematically. Since a ray is a line, we can write any point on the ray as:
Note that we require a non-negative value for the ray parameter
Planes are useful to represent things like the ground. To describe an infinite plane in 3d, we need (1) a point on the plane (
To calculate the intersection of a ray with a plane, we can inject our ray equation into the plane equation - i.e. replacing
When the plane and ray are parallel, then
There's a common expression in physics and engineering: "assume a sphere," which is used when approximating complicated physical systems with simpler sphere. It's said that this originated with theoretical physicists assuming that cows can be represented as spheres. We will assume a lot of things are spheres, like the meatballs in Cloudy with a Chance of Meatballs or the planets in the Solar System.
A sphere is defined entirely by a center
where
Now, just like with the derivation for ray-plane intersections, we'll substitute
Notice that this is a quadratic equation in
Using the quadratic formula gives:
Note that we canceled a bunch of 2's. What happens if
It might seem like we should always use
Use the editor below to practice programming a ray-sphere intersection.
|
In the image at the top-right of this lecture, we can model the ground and meatballs using a plane and spheres, respectively. But how should we model some of the characters? These more complicated surfaces can be represented by a bunch of triangles called a mesh. We'll talk more about meshes later on, but the basic building block of a mesh is a triangle. Other shapes are possible too but triangles are the most flexible so that's what we'll focus on.
This means we need to be able to intersect rays with 3d triangles. We'll represent triangles by three 3d points:
Wait, where did this
To find the ray-triangle intersection, we'll set this expression equal to our ray equation:
Note that we have 3 equations here (for each of the three components of the 3d vectors) and we have three unknowns:
We can use the glMatrix
function in the mat3
namespace called invert
to invert the matrix (doc) and then the transformMat3
function in the vec3
namespace to obtain the final solution (doc). Let glMatrix
:
mat3.invert
.vec3.transformMat3
.Once you've solved the system of equations you should check if
We did a lot of math today, starting with some algebra, then doing operations with vectors and finally setting up a system of equations with matrices and vectors. We didn't really talk about Step 2c in the ray tracing algorithm. We'll talk about shading next week, but for now we'll just assign a constant color to each shape in our scene. In a few weeks, we'll also talk about more general views and how to make some of the intersection calculations faster.
In class, we developed our first ray tracer from scratch, practicing with (1) calculating image plane dimensions (
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Week 03: Exercises</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.8.1/gl-matrix-min.js"></script>
</head>
<body>
<canvas id="raytracing-canvas" width="512" height="512"></canvas>
<script type="text/javascript">
const canvas = document.getElementById("raytracing-canvas");
const context = canvas.getContext("2d");
const nx = canvas.width;
const ny = canvas.height;
let image = context.createImageData(nx, ny);
const eye = vec3.fromValues(0, 0, 0); // position of the camera
const fov = Math.PI / 3; // field-of-view
const aspectRatio = nx / ny;
class Ray {
constructor(origin, direction) {
this.origin = origin;
this.direction = direction;
}
}
const setPixel = function (image, i, j, color) {
const offset = 4 * (image.width * j + i);
image.data[offset + 0] = color[0] * 255;
image.data[offset + 1] = color[1] * 255;
image.data[offset + 2] = color[2] * 255;
image.data[offset + 3] = 255;
};
class Sphere {
constructor(center, radius) {
this.center = center;
this.radius = radius;
}
/**
* @param {Ray} ray with .origin {vec3} and .direction {vec3}.
* @return {Number} ray parameter (t) of closest intersection point
* (undefined if no intersection with this sphere).
**/
intersect(ray) {
// exercise 3a: ray-sphere intersection
const radius = this.radius;
const center = this.center;
let u = vec3.create();
vec3.subtract(u, ray.origin, center);
let B = vec3.dot(ray.direction, u);
let C = vec3.dot(u, u) - radius * radius;
let disc = B * B - C;
if (disc < 0) return undefined;
let tmin = -B - Math.sqrt(disc);
return tmin;
}
}
// exercise 1: calculate width and height of image plane
const d = 1;
const h = 2 * d * Math.tan(fov / 2);
const ar = nx / ny; // also w / h
const w = ar * h;
let sphere = new Sphere(vec3.fromValues(0, 0, -5), 0.5);
for (let j = 0; j < ny; ++j) {
for (let i = 0; i < nx; ++i) {
// exercise 2: calculate pixel coordinates
const x = -0.5 * w + (w * (i + 0.5)) / nx;
const y = -h / 2 + (h * (ny - 0.5 - j)) / ny;
const z = -d;
// exercise 3b: create ray, intersect with sphere and set color
let color = vec3.fromValues(0, 0, 1);
let r = vec3.fromValues(x, y, z);
vec3.normalize(r, r); // a better idea is to normalize in the Ray constructor
const ray = new Ray(eye, r);
let t = sphere.intersect(ray);
if (t) {
color = vec3.fromValues(1, 0, 0);
}
setPixel(image, i, j, color);
}
}
context.putImageData(image, 0, 0);
</script>
</body>
</html>