After following the first 6 chapters of "Ray Tracing in One Weekend" by Peter Shirley, I have created a small raytracer capable of rendering spheres in various positions and differing colors. Below, you can see two spheres: one extremely large one colored green and another much smaller one colored based on its surface norms.
Both of the above spheres (with a blue to white gradient background) were rendered in real time starting from the moment you opened this page of the website.
Here is a small writup on what it took me to get to this point:
- My Goals
- Create a website on GitHub Pages.
- Learn JavaScript enough that I can begin coding the raytracer in JavaScript.
- Follow the first 6 chapters of "Ray Tracing in 1 Weekend," coding the basic setup for the ray tracer.
- My Results
- I created a flatpage website that will work on GitHub Pages.
- I learned JavaScript enough to be able to begin coding the ray tracer in the programming language.
- I followed the first 6 chapters of "Ray Tracing in 1 Weekend" and set up the ray tracer so that it has the basic building blocks and can raytrace simple images. Namely, I set up:
- a vector class for doing calculations in a 3D space by using 3D points and for representing RGB colors
- a camera for looking into the world
- an abstract base class for hittable objects so that eventually more than one shape can be put in the ray tracer's world and thus seen in it
- a hittable sphere class for representing spheres in the world that can be hit by light rays and thus looked at by the ray tracer
- a hittable list so that the ray tracer can handle having multiple objects in the world
- a main function that ray traces the image produced by the current world and camera and outputs the result to an HTML canvas for user viewership
- My Work
- I spent multiple weeks learning JavaScript/HTML/CSS.
- I spent multiple days trying to build my GitHub Pages website using Python flask as an HTML generator. That did not work out, so now I am using a bunch of flat HTML pages without a generator.
- It took me multiple days to go through the first 6 chapters of "Ray Tracing in One Weekend," write the code for the ray tracer, and debug the issues my code had.
- What I Learned
- HTML
- the DOM
- how HTML documents are generally formatted
- what HTML elements exist
- how HTML elements interact with one another
- CSS
- general layout
- selecting classes vs tags vs ids
- the styling available for each HTML element
- how to position elements using CSS
- CSS Grid
- JavaScript (while I have learned other programming languages before, I had to learn how many things look/work in JavaScript in particular)
- classes
- functions
- loops
- conditionals
- variables/variable scopes
- asyncronous programming
- event handling
- how to access/manipulate HTML elements and the DOM
- and more
- How to set up a ray tracer.
- How colors work on the computer.
- How to trace a ray and see what it hits in a 3D space.
- How to create objects that can be hit by a ray.
- How to handle having multiple objects in the ray tracer's world.
- How to color a pixel based on what a ray from the pixel into the world hit.
- How to differentiate hitting the frontside or backside of an object with a ray.
Below is the source code for the raytracer that rendered the above image:
// ----------------------------------------------------------------------------
// Helper Classes
class Vec3 {
constructor(x=0, y=0, z=0) {
this.x = x;
this.y = y;
this.z = z;
}
get r() { return this.x; }
set r(newR) { this.x = newR; }
get g() { return this.y; }
set g(newG) { this.y = newG; }
get b() { return this.z; }
set b(newB) { this.z = newB; }
copy() {
return new Vec3(this.x, this.y, this.z);
}
negative() {
return new Vec3(-this.x, -this.y, -this.z);
}
minusEq(vec3) {
this.x -= vec3.x;
this.y -= vec3.y;
this.z -= vec3.z;
return this;
}
plusEq(vec3) {
this.x += vec3.x;
this.y += vec3.y;
this.z += vec3.z;
return this;
}
timesEq(vec3) {
this.x *= vec3.x;
this.y *= vec3.y;
this.z *= vec3.z;
return this;
}
divEq(vec3) {
this.x /= vec3.x;
this.y /= vec3.y;
this.z /= vec3.z;
return this;
}
length() {
return Math.sqrt(this.lengthSquared());
}
lengthSquared() {
return (this.x * this.x) + (this.y * this.y) + (this.z * this.z);
}
toString() {
return \`<Vec3(\${this.x}, \${this.y}, \${this.z})>\`;
}
toColorString() {
return \`rgb(\${Math.floor(255.99 * this.r)},
\${Math.floor(255.99 * this.g)}, \${Math.floor(255.99 * this.b)})\`
}
plus(vec3) {
return new Vec3((this.x + vec3.x), (this.y + vec3.y), (this.z + vec3.z));
}
plusNum(num) {
return new Vec3((this.x + num), (this.y + num), (this.z + num));
}
minus(vec3) {
return new Vec3((this.x - vec3.x), (this.y - vec3.y), (this.z - vec3.z));
}
minusNum(num) {
return new Vec3((this.x - num), (this.y - num), (this.z - num));
}
minusedByNum(num) {
return new Vec3((num - this.x), (num - this.y), (num - this.z));
}
times(vec3) {
return new Vec3((this.x * vec3.x), (this.y * vec3.y), (this.z * vec3.z));
}
timesNum(num) {
return new Vec3((this.x * num), (this.y * num), (this.z * num));
}
divBy(vec3) {
return new Vec3((this.x / vec3.x), (this.y /vec3.y), (this.z /vec3.z));
}
divByNum(num) {
return new Vec3((this.x / num), (this.y / num), (this.z / num));
}
dot(vec3) {
return (this.x * vec3.x) + (this.y * vec3.y) + (this.z * vec3.z);
}
cross(vec3) {
return new Vec3(
this.y * vec3.z - this.z * vec3.y,
this.z * vec3.x - this.x * vec3.z,
this.x * vec3.y - this.y * vec3.x
);
}
unitVector() {
return this.divByNum(this.length());
}
}
// Vec3 aliases
const Color = Vec3;
const Point3D = Vec3;
const WHITE = new Color(1.0, 1.0, 1.0);
const BLACK = new Color(0.0, 0.0, 0.0);
/**
* A class that represents a ray of light.
*/
class Ray {
constructor({origin, direction}) {
this.origin = origin;
this.direction = direction;
}
/**
* Returns the position of this ray at the given time (Number) t
*/
at(t) {
return (this.origin.plus(this.direction.timesNum(t)))
}
}
class HitRecord {
constructor({}={}) {
this.p = new Point3D(0, 0, 0); // Point3D
this.normal = new Vec3(0, 0, 0); // Vec3
this.t = 0; // Number
// bool, true if this record represents the face of an object facing
// towards the source of the arry or false if this represents the hit of
// a face facing away from the source of the ray
this.frontFace;
}
setFaceNormal(ray, outwardNormal) {
this.frontFace = ray.direction.dot(outwardNormal) < 0;
this.normal = this.frontFace ? outwardNormal : outwardNormal.negative();
}
become(hitRecord) {
this.p = hitRecord.p;
this.normal = hitRecord.normal;
this.t = hitRecord.t;
this.frontFace = hitRecord.frontFace;
}
}
class Hittable {
hitBy(ray) {
throw "hitBy is not Implemented for this hittable object.";
}
}
class Sphere extends Hittable {
constructor({center, radius}={}) {
super();
this.center = center; // Vec3
this.r = radius; // Number
}
hitBy(ray, tMin, tMax, hitRecord) {
let oc = ray.origin.minus(this.center);
let a = ray.direction.lengthSquared();
let halfB = oc.dot(ray.direction);
let c = oc.lengthSquared() - (this.r * this.r);
let discriminant = (halfB * halfB) - (a * c);
if (discriminant < 0) { return false; }
let sqrtd = Math.sqrt(discriminant);
// find the nearest root that lies in the acceptable range.
let root = (-halfB - sqrtd) / a;
if ((root < tMin) || (tMax < root)) {
let root = (-halfB + sqrtd) / a;
if ((root < tMin) || (tMax < root)) {
return false;
}
}
hitRecord.t = root;
hitRecord.p = ray.at(hitRecord.t);
let outwardNormal = hitRecord.p.minus(this.center).divByNum(this.r);
hitRecord.setFaceNormal(ray, outwardNormal);
return true;
}
}
class HittableList {
constructor({}={}) {
this.hittables = [];
}
hitBy(ray, tMin, tMax, hitRecord) {
let tempHitRecord = new HitRecord();
let hitAnything = false;
let closestSoFar = tMax;
this.hittables.forEach((hittable) => {
if (hittable.hitBy(ray, tMin, closestSoFar, tempHitRecord)) {
hitAnything = true;
closestSoFar = tempHitRecord.t;
hitRecord.become(tempHitRecord);
}
});
return hitAnything;
}
}
// ----------------------------------------------------------------------------
// Constants
const INFINITY = Infinity;
const PI = 3.1415926535897932385;
// ----------------------------------------------------------------------------
// Render
function rayColor(ray, world) {
let hitRecord = new HitRecord();
if (world.hitBy(ray, 0, INFINITY, hitRecord)) {
return ((hitRecord.normal.plus(WHITE)).timesNum(0.5));
}
// Background
let unitDirection = ray.direction.unitVector();
let t = 0.5 * (unitDirection.y + 1.0);
return WHITE.timesNum(1.0 - t).plus((new Color(0.5, 0.7, 1.0)).timesNum(t));
}
/**
* The main function. It asyncronously waits for every row of the raytraced
* image to be rendered, then writes the resulting colors to the canvas.
*
* Uses async so that the rendering is non-blocking and thus the internet
* browser will not seize up.
*/
async function main(canvas) {
if (canvas === undefined) {
return;
}
const context = canvas.getContext('2d');
// Helper Function that writes a pixel to the context
function writePixel(imageX, imageY, color) {
context.fillStyle = color;
context.fillRect(imageX, imageY, 1, 1); // draw the pixel
}
// Image
const ASPECT_RATIO = 16.0 / 9.0;
const IMAGE_WIDTH = 400.0;
const IMAGE_HEIGHT = IMAGE_WIDTH / ASPECT_RATIO;
// Make sure that the canvas is the correct width and height
canvas.width = IMAGE_WIDTH;
canvas.height = IMAGE_HEIGHT;
// World
let world = new HittableList();
world.hittables.push(new Sphere({center:new Point3D(0, 0, -1), radius:0.5}));
world.hittables.push(new Sphere({center:new Point3D(0, -100.5, -1), radius:100}));
// Camera
const viewportHeight = 2.0;
const viewportWidth = 2.0 * viewportHeight;
const focalLength = 1.0;
const origin = new Point3D(0.0, 0.0, 0.0);
const horizontal = new Vec3(viewportWidth, 0.0, 0.0);
const vertical = new Vec3(0.0, viewportHeight, 0.0);
const lowerLeftCorner = origin.minus(horizontal.divByNum(2.0))
.minus(vertical.divByNum(2.0)).minus(new Vec3(0, 0, focalLength));
// --- Render
// Clear the canvas
context.save();
context.fillStyle = "white";
context.fillRect(0, 0, canvas.width, canvas.height)
context.restore();
/**
* Renders a row of pixels of the picture.
*/
async function rowColors(y) {
y = y + 1;
let colors = [];
for (let x = 0; x < IMAGE_WIDTH; ++x) {
let u = x / (IMAGE_WIDTH - 1);
let v = y / (IMAGE_HEIGHT - 1);
let ray = new Ray({
origin:origin,
direction:lowerLeftCorner.plus(horizontal.timesNum(u))
.plus(vertical.timesNum(v))
});
let raycolor = rayColor(ray, world).toColorString();
// Have to do IMAGE_HEIGHT - y because raytracer assumes that
// positive y goes upward, but on canvas it goes downward
writePixel(x, IMAGE_HEIGHT - y, raycolor);
}
return colors;
}
let rows = [];
// Use async to calculate each row
for (let y = IMAGE_HEIGHT - 1; y >= 0; --y) {
rows.push(rowColors(y));
}
// Make this function wait asyncronously for every pixel to be drawn before ending
await Promise.all(rows);
}