Sunday, March 06, 2016 Eric Richards

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.
Ultimately, we'll be producing the same image as in the last installment, but with considerably less boilerplate code, and lay the groundwork for making our lives much easier going forward when we get to some more meaty topics. As always, the full code is available on GitHub, but I'll be presenting the full code for this example in this post.

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.