ndesmic

Posted on

# Raytracing 3D Engine from Scratch Part 3: Planes and Reflection

We can create spheres all right but that's not how most objects we want will be defined. What we really want is flat surfaces to build up true meshes.

## Cleanup

We previous had a method `createMeshes` which was a holdover from the WebGL engine. Since the raytracer can do a lot more than meshes, I've renamed this and the corresponding `this.meshes` property to `createObjects` and `this.objects` respectively.

Another thing I did was rename `multiplyVector` to `scaleVector`. This better describes the operation since multiplication can have several meanings.

Also, each object will now have a `type` property, which will define what exactly it is so we can perform the correct algorithm.

## Intersecting Planes

The next step is doing intersections with a plane. Mathematically, this is the same thing we do for circles, in fact it's a bit easier. We plug the equation for a ray `origin + t * direction` into the equation for a plane `dot(position, normal) - constant` for the `position` value. Some re-arranging terms gets us:

``````intersectPlane(ray, plane){
return (plane.offset - dotVector(ray.origin, plane.normal)) / dotVector(ray.direction, plane.normal);
}
``````

We also need to update `intersectObjects` to use the new method if the object is a plane:

``````intersectObjects(ray) {
let closest = { distance: Infinity, object: null };
for (let object of Object.values(this.objects)) {
let distance;
switch(object.type){
case "sphere": {
distance = this.intersectSphere(ray, object);
break;
}
case "plane": {
distance = this.intersectPlane(ray, object);
break;
}
}
if (distance != undefined && distance < closest.distance && distance > 0.001) {
closest = { distance, object };
}
}
return closest;
}
``````

Note that I added a new term on the if condition `distance > 0.001`. This prevents some problems when something intersects the origin. If we placed a plane such that it intersects the camera's position we'll basically get a black screen because the collision is at the camera location. By forcing collisions to not be too close, we can prevent that.

Next we need to get the normal of the plane. This is actually given by the plane equation but we should make sure it's actually normalized or it won't work correctly. I also combined this is with the sphere normal logic so we can get the normals of either.

``````getNormal(collisionPosition, object){
switch(object.type){
case "sphere": {
return normalizeVector(subtractVector(collisionPosition, object.position));
}
case "plane": {
return normalizeVector(object.normal);
}
}
}
``````

Finally in `getSurfaceInfo` we use the new method instead of hard-coding the sphere normal.

``````getSurfaceInfo(collisionPosition, object, ray){
let color = [0,0,0];
for(const light of Object.values(this.lights)){
if(this.isVisible(collisionPosition, light.position)){
const normal = this.getNormal(collisionPosition, object);
const toLight = subtractVector(light.position, collisionPosition);
const lightAmount = multiplyVector(light.color, dotVector(toLight, normal));
if(object.specularity){
const toCamera = normalizeVector(subtractVector(ray.origin, collisionPosition));
const baseSpecular = clamp(dotVector(halfVector, normal), 0.0, 1.0);
const specularMagnitude = baseSpecular ** object.gloss;
const specularLight = componentwiseMultiplyVector(light.color, [specularMagnitude, specularMagnitude, specularMagnitude, 1.0]);
}
}
}
return [...color, 1];
}
``````

We'll add a little plane to our scene:

``````plane: {
type: "plane",
normal: [0,1,-1],
offset: 0,
color: [0.3,0.3,0.3,1]
}
``````

And voila! It all just sorta works with shadowing and all.

## Reflections

Once of the nice things about raytracing is the ability to do realistic reflections. In order to do this we'll need to calculate the bounces the rays make off of reflective objects.

This will replace our specular implementation because this is realistically how specular should work. It's the same as how phong originally worked except once we reflect the ray direction we follow to the next surface and calculate the color again (and keep following the bounces). The final color is scaled by the specular amount which controls the reflectivity of the surface. That is specularity 1 is a pure mirror which might not work the way you expect (especially if there is also diffuse which means more light is reflected than came in).

Reflection around a normal looks like this:

``````//vector.js
export function reflectVector(vec, normal) {
return [
vec[0] - 2 * dotVector(vec, normal) * normal[0],
vec[1] - 2 * dotVector(vec, normal) * normal[1],
vec[2] - 2 * dotVector(vec, normal) * normal[2],
];
}
``````
``````getSurfaceInfo(collisionPosition, object, ray, bounceCounter){
let color = [0,0,0];
for(const light of Object.values(this.lights)){
if(this.isVisible(collisionPosition, light.position)){
const normal = this.getNormal(collisionPosition, object);
const toLight = normalizeVector(subtractVector(light.position, collisionPosition));
const lightAmount = scaleVector(light.color, dotVector(toLight, normal));
if(object.specularity){
const reflectionDirection = reflectVector(ray.direction, normal);
const reflectionColor = this.raytrace({ origin: collisionPosition, direction: reflectionDirection }, bounceCounter - 1);
const specularLight = clamp(scaleVector(reflectionColor, object.specularity), 0.0, 1.0);
}
}
}
return [...color, 1];
}
``````

You will notice that I added an extra parameter `bounceCounter`. Since the recursive look up can be near infinite we need to cut it off at some point. We make a slight modification to `raytrace` to deal with it:

``````raytrace(ray, bounceCounter = MAX_BOUNCES){
if(bounceCounter <= 0){
return BACKGROUND_COLOR;
}
const intersection = this.intersectObjects(ray);
if (intersection.distance === Infinity || intersection.distance === -Infinity) {
return BACKGROUND_COLOR;
}
const collisionPoint = addVector(ray.origin, scaleVector(ray.direction, intersection.distance));
return this.getSurfaceInfo(collisionPoint, intersection.object, ray, bounceCounter);
}
``````

We're counting down from `MAX_BOUNCES` and if we hit zero we simply return the default color. I've defined `MAX_BOUNCES` to 3. Also, I extracted the background color so it can be easily changed, white is a bad choice when testing reflections.

I made the background cornflower blue and adjusted the camera a bit. The sphere (0,0,0) is sitting on a plane pointing upward at y = -1. The plane is a grey color with 0.7 specularity.

We can see a reflection off the plane.

## Ambient Lighting

At this point it's getting hard to control the color of things because the light keeps blowing out into something too bright because we aren't applying any conservation of energy. I don't really want to open that can of worms yet, so lets give us a little bit of extra room to work with. We can introduce a concept of "ambient" light. This is light coming from all directions so it's just added to everything. This helps prevent the shadows from being so harsh.

In `getSurfaceInfo` we'll just add the light to the color at the start: `let color = componentwiseMultiplyVector(BACKGROUND_LIGHT, object.color);`

Often times the object material itself will specify the ambient color along with diffuse and specular but I don't really see much need to do so.

This way we can brighten the whole scene without messing with the lights.

## Taking it to the max

Adding an extra light source and sphere and changing the background to something that blows out less:

Reflections, Reflections of reflections, shadows, reflection of shadows, diffuse, and ambient lighting. Still very satisfying the results we get without too much code.

You can find the code here: https://github.com/ndesmic/geort/tree/V3