Ray Tracing #2: Abstractions
It's going to take me considerably longer than one weekend to build out a ray tracer...
Last time, I laid the groundwork to construct a PPM image and output a simple gradient image, like the one below.
This time around, I'm going to focus on building some useful abstractions that will make work going forward easier. This is going to focus on two areas:
- A Vector3 class, which will be helpful for representing 3D points, directional vector, RGB colors and offsets. We'll implement some useful operators and geometric methods in addition.
- A Bitmap class, which will represent our output raster and handle the details of saving that raster out as a PPM image file.
Vector3 Struct
3D vectors are a bread-and-butter data type for graphics, and there are a lot of very robust implementations available. For educational purposes, we're going to build our own, though. For a somewhat more feature-complete implementation, you might want to check out the SlimDX or SharpDX vector types.
As a baseline, a 3D vector requires floating-point values for the X, Y, and Z components. We're going to use the same type to represent points, directions, offsets and colors, for simplicity, although it might make sense to leverage the type system more, and implement separate Vector3, Point3, Offset3 and Color3 types, to prevent nonsensical operations, like adding a color to a point. For the present, I'll leave that up as an exercise for the reader.
Our Vector3 type is going to be a fairly simple value type, using public member fields to store our X, Y and Z components. There are a number of other design decisions that one could make for alternate implementations - another fairly common pattern is to use a three-element array to store the components - and we'll use plain old 32-bit floats, rather than higher precision doubles. In addition to the X, Y, and Z components, we'll include some simple properties to alias these fields when we are treating the vector as an RGB color, as well as some simple factory properties to create unit and zero vectors. I'm also going to provide a C# indexer, which will allow us to treat a vector as an array of floats; this possibly is a violation of YAGNI, but can be handy in some situations. Lastly, we'll implement some arithmetic operator overloads, and some simple geometric methods for getting the length of a vector, normalizing a vector, and computing the dot and cross products of vectors.
namespace OneWeekendRT.Util {
using System;
public struct Vector3 {
// XYZ fields
public float X;
public float Y;
public float Z;
// RGB aliases
public float R { get { return X; } set { X = value; } }
public float G { get { return Y; } set { Y = value; } }
public float B { get { return Z; } set { Z = value; } }
// Indexer for treating the vector as an array
public float this[int i] {
get {
switch (i) {
case 0:
return X;
case 1:
return Y;
case 2:
return Z;
default:
throw new IndexOutOfRangeException("Vector 3 only allows indices 0, 1, and 2");
}
}
set {
switch (i) {
case 0:
X = value;
break;
case 1:
Y = value;
break;
case 2:
Z = value;
break;
default:
throw new IndexOutOfRangeException("Vector 3 only allows indices 0, 1, and 2");
}
}
}
// Simple constructor
public Vector3(float x, float y, float z) {
X = x;
Y = y;
Z = z;
}
// Factory properties for creating common vectors
public static Vector3 Zero { get { return new Vector3(0, 0, 0); } }
public static Vector3 UnitX { get { return new Vector3(1, 0, 0); } }
public static Vector3 UnitY { get { return new Vector3(0, 1, 0); } }
public static Vector3 UnitZ { get { return new Vector3(0, 0, 1); } }
// unary negation
public static Vector3 operator -(Vector3 v) {
return new Vector3(-v.X, -v.Y, -v.Z);}
// Binary addition and subtraction operators
public static Vector3 operator +(Vector3 a, Vector3 b) {
return new Vector3(a.X+b.X, a.Y +b.Y, a.Z + b.Z);
}
public static Vector3 operator -(Vector3 a, Vector3 b) {
return new Vector3(a.X - b.X, a.Y - b.Y, a.Z - b.Z);
}
// Scaling operators
public static Vector3 operator *(Vector3 a, float f) {
return new Vector3(a.X *f, a.Y *f, a.Z *f);
}
public static Vector3 operator *(float f, Vector3 a) {
return a*f;
}
public static Vector3 operator /(Vector3 a, float f) {
return new Vector3(a.X / f, a.Y / f, a.Z / f);
}
public static Vector3 operator /(float f, Vector3 a) {
return a*f;
}
// Lengths of the vector
public float Length { get { return (float)Math.Sqrt(X * X + Y * Y + Z * Z); } }
public float LengthSquared { get { return X * X + Y * Y + Z * Z; } }
// Normalize to a unit-vector
public Vector3 Normalize() {
var length = Length;
return new Vector3(X / length, Y / length, Z / length);
}
// Dot product
public float Dot(Vector3 v) { return X * v.X + Y * v.Y + Z * v.Z; }
// Cross product
public Vector3 Cross(Vector3 v) {
return new Vector3(Y*v.Z - Z*v.Y, -(X*v.Z - Z*v.X), X*v.Y - Y*v.X);
}
}
}
As we go along, we'll doubtless find additional operations that make sense to add to this Vector3 type, but this is a good first cut. With that out of the way, let's move on to making the Bitmap type.
Bitmap Class
The code in the previous example to generate the final PPM image wasn't particularly gnarly, but it introduces quite a bit of noise and boilerplate that obscures the more interesting ray tracing code. Ultimately, we don't really care about the details of the PPM image format when we are generating images, we just want a generic raster of pixels that we can set color values for. Additionally, if we decide in the future that we'd rather save our images to a different format, or, instead of saving the image to a file, blit it to the screen, having a more generic abstraction would be useful. So let's go do that.
namespace OneWeekendRT.Util {
using System;
using System.IO;
using System.Text;
public class Bitmap {
// dimensions
public int Width { get; private set; }
public int Height { get; private set; }
// pixel array
private readonly Vector3[] _bitmap;
public Bitmap(int width, int height) {
Width = width;
Height = height;
_bitmap = new Vector3[width * height];
}
// indexer into pixel values
public Vector3 this[int x, int y] {
get {
if (x >= Width || x < 0) {
throw new ArgumentOutOfRangeException("x", x, "Invalid x-coordinate");
}
if (y >= Height || y < 0) {
throw new ArgumentOutOfRangeException("y", y, "Invalid y-coordinate");
}
return _bitmap[y * Width + x];
}
set {
if (x >= Width || x < 0) {
throw new ArgumentOutOfRangeException("x", x, "Invalid x-coordinate");
}
if (y >= Height || y < 0) {
throw new ArgumentOutOfRangeException("y", y, "Invalid y-coordinate");
}
_bitmap[y * Width + x] = value;
}
}
// Save the image as a PPM file
public void SavePPM(string filename) {
var sb = new StringBuilder("P3\n");
sb.AppendLine(Width + " " + Height);
sb.AppendLine("255");
for (var y = Height-1; y >=0; y--) {
for (var x = 0; x < Width; x++) {
var pixel = this[x, y];
sb.AppendFormat("{0} {1} {2}\n", (int)(pixel.R * 255.99), (int)(pixel.G * 255.99), (int)(pixel.B * 255.99));
}
}
File.WriteAllText(filename, sb.ToString());
}
}
}
As you can see, this Bitmap class is basically a wrapper around a Width x Height array of Vector3 values. We've provided an indexer that lets us easily get and set individual pixels, and we've hidden the PPM saving gunk behind a method.
Putting it Together
Using our new Vector3 and Bitmap types, our example program now looks like this, which is much cleaner than before:
using System.Diagnostics;
namespace OneWeekendRT {
using OneWeekendRT.Util;
public class Vector {
public static void Main(string[] args) {
var bitmap = new Bitmap(400, 200);
for (var y = bitmap.Height - 1; y >= 0; y--) {
for (var x = 0; x < bitmap.Width; x++) {
var color = new Vector3((float)x / bitmap.Width, (float)y / bitmap.Height, 0.2f);
bitmap[x, y] = color;
}
}
bitmap.SavePPM("vector.ppm");
Process.Start("vector.ppm");
}
}
}
We're still taking baby steps here, but we've almost laid enough groundwork to start doing some more exciting things. Next time, we'll get the rudiments of a camera going, and get some actual rays tracing.