Eccentric Developments


Ray Tracing

Full disclosure, I won't be explaining how the ray tracing algorithm works in detail, you can go to this wikipedia article to learn more. Maybe some day I'll do an in-depth explanation, but that is not today.

In this article I want to focus on a very simple thing: modifiying the ray casting algorithm to support simple ligthing, and thus, converting it in a very basic ray tracing implementation.

This whole implementation is based on the previous ray casting article, not all the code is visible or editable, but you can see it if you choose to look at this page's source. Only the code that needs to be modified is going to be shown in code editors.

The first code that needs updating is createScene, we want to add an sphere, and a point light. Point lights as their name implies, is just a single point in 3D space that emits light.

function createScene() {
  return {
    scene: [
      {
        center: [0.0, 0.0, 0.0],
        radius: 5.0,
        color: [0, 0, 1.0],
      },
      {
        center: [0.0, -9000.0 - 5.0, 0.0],
        radius: 9000.0,
        color: [1.0, 1.0, 1.0],
      },
    ],
    lights: [
      {
        origin: [10.0, 10.0, -10.0]
      }
    ]
  };
}
function createScene() {
  return {
    scene: [
      {
        center: [0.0, 0.0, 0.0],
        radius: 5.0,
        color: [0, 0, 1.0],
      },
      {
        center: [0.0, -9000.0 - 5.0, 0.0],
        radius: 9000.0,
        color: [1.0, 1.0, 1.0],
      },
    ],
    lights: [
      {
        origin: [10.0, 10.0, -10.0]
      }
    ]
  };
}

To calculate the light that incides in a sphere, we need first to know the normal of the surface at the point that the ray intersected, this is easily calculated as substracting the point the ray hit the sphere from its center.

function createIntersectionFunction(args) {
  const { vector } = args;
  const intersect = (ray, sphere) => {
    const { center, radius } = sphere;
    const oc = vector(ray.origin).sub(center);
    const a = vector(ray.direction).dot(ray.direction).value();
    const b = oc.dot(ray.direction).value();
    const c = oc.dot(oc).value() - radius * radius;
    const dis = b * b - a * c;

    if (dis > 0) {
      const e = Math.sqrt(dis);
      let t = (-b - e) / a;
      if (t > 0.007) {
        const point = vector(ray.direction).scale(t).add(ray.origin).value();
        return {
          hit: true,
          distance: t,
          point,
          // This is the new code to calculate the normal
          normal: vector(point).sub(center).unit().value(),
        };
      }

      t = (-b + e) / a;
      if (t > 0.007) {
        const point = vector(ray.direction).scale(t).add(ray.origin).value();
        return {
          hit: true,
          distance: t,
          point,
          // This is the new code to calculate the normal
          normal: vector(point).sub(center).unit().value(),
        };
      }
    }
    return {
      hit: false,
    };
  };
  return {
    intersect,
  };
}

The normal of the intersection point is included as part of the hit structure, so it will allow us to calculate the angle of incidence of ligth.

To approximate the amount of light that reaches the hit point, the next step is to add a new shading function.

function createShadingFunction(args) {
  const { vector, trace, lights } = args;
  const shading = (point, normal) =>
    lights.map((light) => {
      const origin = vector(point).add(vector(normal).scale(0.01)).value();
      const tmp = vector(light.origin).sub(origin);
      const maxDistance = tmp.norm().value();
      const direction = tmp.unit().value();
      const lightRay = {
            origin,
            direction,
          };
      const intersection = trace(lightRay);
      if(intersection.hit && intersection.distance < maxDistance) {
        return 0;
      }
      return Math.max(0, vector(direction).dot(normal).value());
    }).reduce((acc, v) => acc + v, 0);
  return {
    shading
  };
}

The shading function calculates how much light reaches the point, if at all. This function is used in the generateBitmap function to apply shading for each pixel where an object has been found.

function* generateBitmap(args) {
  const {
    traceResults,
    imageGeometry: { width, height },
    shading,
    vector,
  } = args;
  const bitmap = new Uint32Array(width * height);
  let count = 0;
  let idx = 0;

  for(const result of traceResults) {
    const { intersection } = result;
    const { hit, sphere, point, normal } = intersection;
    let pixel = 0xff000000;
    if (hit) {
      const intensity = shading(point, normal);
      const [r, g, b] = vector(sphere.color).scale(intensity).value();
      pixel = (255 << 24) | (Math.floor(b * 255) << 16) | (Math.floor(g * 255) << 8) | Math.floor(r * 255);
    }
    bitmap[idx++] = pixel;
    if (++count === width * 16) {
        count = 0;
        yield;
    }
  }

  return {
    bitmap,
  };
}

There are more nuisances to physical accurate lighting, for now, we only use the angle of indicende to apply lighting to the found surfaces. Another simple change that can be included later is to add intensity to the point light so it will illuminate less the farther the surface is.

const canvas = document.getElementById("canvas-1");
const ctx = canvas.getContext("2d");
const width = canvas.width;
const height = canvas.height;
const renderingPipeline = pipeline([
  createAspectRatioFunction,
  createScene,
  createCamera,
  createImageGeometry,
  createVectorFunction,
  calculatePrimaryRays,
  createIntersectionFunction,
  createTraceFunction,
  createShadingFunction,
  tracePrimaryRays,
  generateBitmap,
]);
renderingPipeline({ width, height }).then((result) => {
    const { bitmap } = result;
    const imageData = new ImageData(new Uint8ClampedArray(bitmap.buffer), width);
    ctx.putImageData(imageData, 0, 0);
});

This ray tracing implementaiton, as shown, is a very simple update to the ray casting algorithm that generates a nice shading effect on the objects in the scene.

You can play around with the scene, add lights and more spheres, it only gets more interesting from here.

Enrique CR - 2023-05-20