aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Colour.ts15
-rw-r--r--src/Framebuffer.ts38
-rw-r--r--src/Geometry.ts31
-rw-r--r--src/Light.ts5
-rw-r--r--src/Material.ts11
-rw-r--r--src/Raytracer.ts273
-rw-r--r--src/Vector.ts36
-rw-r--r--src/index.ts84
8 files changed, 493 insertions, 0 deletions
diff --git a/src/Colour.ts b/src/Colour.ts
new file mode 100644
index 0000000..4f2c4a6
--- /dev/null
+++ b/src/Colour.ts
@@ -0,0 +1,15 @@
+import {Vector} from './Vector';
+
+export class Colour extends Vector {
+ constructor(readonly r: number, readonly g: number, readonly b: number) {
+ super(r, g, b);
+ }
+
+ static fromVector(vector: Vector): Colour {
+ return new Colour(vector.x, vector.y, vector.z);
+ }
+
+ toVector(): Vector {
+ return new Vector(this.r, this.g, this.b);
+ }
+}
diff --git a/src/Framebuffer.ts b/src/Framebuffer.ts
new file mode 100644
index 0000000..b9926d2
--- /dev/null
+++ b/src/Framebuffer.ts
@@ -0,0 +1,38 @@
+import {Colour} from './Colour';
+
+export class Framebuffer {
+ readonly width: number;
+ readonly height: number;
+ readonly canvasContext: CanvasRenderingContext2D;
+ readonly canvasImageData: ImageData;
+
+ constructor(width: number, height: number) {
+ this.width = width;
+ this.height = height;
+
+ const canvas = document.getElementById(
+ 'render-output'
+ ) as HTMLCanvasElement;
+
+ canvas.width = this.width;
+ canvas.height = this.height;
+
+ this.canvasContext = canvas.getContext('2d')!;
+ this.canvasImageData = this.canvasContext.createImageData(
+ this.width,
+ this.height
+ );
+ }
+
+ writePixelAt(x: number, y: number, colour: Colour) {
+ const startIdx = (y * this.width + x) * 4;
+ this.canvasImageData.data[startIdx] = colour.r;
+ this.canvasImageData.data[startIdx + 1] = colour.g;
+ this.canvasImageData.data[startIdx + 2] = colour.b;
+ this.canvasImageData.data[startIdx + 3] = 255; // No A
+ }
+
+ flush() {
+ this.canvasContext.putImageData(this.canvasImageData, 0, 0);
+ }
+}
diff --git a/src/Geometry.ts b/src/Geometry.ts
new file mode 100644
index 0000000..4fcc11b
--- /dev/null
+++ b/src/Geometry.ts
@@ -0,0 +1,31 @@
+import {Colour} from './Colour';
+import {Material} from './Material';
+import {Vector} from './Vector';
+
+export class Sphere {
+ constructor(
+ readonly centerPoint: Vector,
+ readonly radius: number,
+ readonly material: Material
+ ) {}
+}
+
+export class Plane {
+ constructor(
+ readonly yPos: number,
+ readonly width: number, // How far the plane extends into x/-x from 0
+ readonly zStartPos: number,
+ readonly zEndPos: number,
+ readonly checkerboardScale: number
+ ) {}
+
+ getMaterialAtPoint(x: number, z: number): Material {
+ let colour: Colour;
+ if ((Math.round(this.checkerboardScale * x) + Math.round(this.checkerboardScale * z)) & 1) {
+ colour = new Colour(15, 15, 15);
+ } else {
+ colour = new Colour(200, 200, 200);
+ }
+ return new Material(colour, 1, 0, 0, 0);
+ }
+}
diff --git a/src/Light.ts b/src/Light.ts
new file mode 100644
index 0000000..1d9c8bd
--- /dev/null
+++ b/src/Light.ts
@@ -0,0 +1,5 @@
+import {Vector} from './Vector';
+
+export class Light {
+ constructor(readonly position: Vector, readonly intensity: number) {}
+}
diff --git a/src/Material.ts b/src/Material.ts
new file mode 100644
index 0000000..6f199a3
--- /dev/null
+++ b/src/Material.ts
@@ -0,0 +1,11 @@
+import {Colour} from './Colour';
+
+export class Material {
+ constructor(
+ readonly diffuseColour: Colour,
+ readonly diffuseAlbedo: number,
+ readonly specularAlbedo: number,
+ readonly reflectionAlbedo: number,
+ readonly specularExponent: number
+ ) {}
+}
diff --git a/src/Raytracer.ts b/src/Raytracer.ts
new file mode 100644
index 0000000..b609604
--- /dev/null
+++ b/src/Raytracer.ts
@@ -0,0 +1,273 @@
+import {Colour} from './Colour';
+import {Framebuffer} from './Framebuffer';
+import {Plane, Sphere} from './Geometry';
+import {Light} from './Light';
+import {Material} from './Material';
+import {Vector} from './Vector';
+
+class Ray {
+ constructor(readonly origin: Vector, readonly direction: Vector) {}
+}
+
+class RayTraceResult {
+ constructor(
+ readonly geometryHit: boolean,
+ readonly hitPoint: Vector,
+ readonly normal: Vector,
+ readonly material: Material
+ ) {}
+}
+
+class RayIntersectionResult {
+ constructor(readonly geometryHit: boolean, readonly rayDistance: number) {}
+}
+
+export interface RaytracerOptions {
+ shadows: boolean;
+ diffuseLighting: boolean;
+ specularLighting: boolean;
+ reflections: boolean;
+ maxRecurseDepth: number;
+ maxDrawDistance: number;
+}
+
+export class Raytracer {
+ constructor(
+ readonly framebuffer: Framebuffer,
+ readonly fov: number,
+ readonly spheres: Sphere[],
+ readonly planes: Plane[],
+ readonly lights: Light[],
+ readonly options: RaytracerOptions
+ ) {}
+
+ render() {
+ const height = this.framebuffer.height;
+ const width = this.framebuffer.width;
+
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const rayX = x + 0.5 - width / 2;
+ const rayY = -(y + 0.5) + height / 2;
+ const rayZ = -height / (2 * Math.tan(this.fov / 2));
+ const rayDirection = new Vector(rayX, rayY, rayZ).normalise();
+ const ray = new Ray(new Vector(0, 0, 0), rayDirection);
+
+ const pixelValue = this.raytrace(ray);
+
+ this.framebuffer.writePixelAt(x, y, pixelValue);
+ }
+ }
+
+ this.framebuffer.flush();
+ }
+
+ private raytrace(ray: Ray, recursionDepth = 0): Colour {
+ const result = this.processSceneGeometry(ray);
+
+ if (recursionDepth > this.options.maxRecurseDepth || !result.geometryHit) {
+ // No hit, show background colour
+ // return new Colour(50, 178, 203);
+ // return new Colour(150, 150, 150);
+ return new Colour(0, 128, 128);
+ }
+
+ let reflectionColour = new Colour(0, 0, 0);
+ if (this.options.reflections) {
+ const reflectionDirection = this.calculateReflection(
+ ray.direction,
+ result.normal
+ );
+ let reflectionOrigin: Vector;
+ if (reflectionDirection.dotProduct(result.normal) < 0) {
+ reflectionOrigin = result.hitPoint.subtract(
+ result.normal.multiply(0.001)
+ );
+ } else {
+ reflectionOrigin = result.hitPoint.add(result.normal.multiply(0.001));
+ }
+ reflectionColour = this.raytrace(
+ new Ray(reflectionOrigin, reflectionDirection),
+ ++recursionDepth
+ );
+ }
+
+ return this.processLighting(result, ray.direction, reflectionColour);
+ }
+
+ private processSceneGeometry(ray: Ray): RayTraceResult {
+ let geometryDistance = 99999;
+ let hitPoint = new Vector(0, 0, 0);
+ let normal = new Vector(0, 0, 0);
+ let material = new Material(new Colour(0, 0, 0), 0, 0, 0, 0);
+
+ this.spheres.forEach(sphere => {
+ const result = this.intersectSphere(ray, sphere);
+
+ if (result.geometryHit && result.rayDistance < geometryDistance) {
+ geometryDistance = result.rayDistance;
+ hitPoint = ray.origin.add(ray.direction.multiply(result.rayDistance));
+ normal = hitPoint.subtract(sphere.centerPoint).normalise();
+ material = sphere.material;
+ }
+ });
+
+ this.planes.forEach(plane => {
+ const result = this.intersectPlane(ray, plane);
+
+ if (result.geometryHit && result.rayDistance < geometryDistance) {
+ geometryDistance = result.rayDistance;
+ hitPoint = ray.origin.add(ray.direction.multiply(result.rayDistance));
+ normal = new Vector(0, 1, 0);
+ material = plane.getMaterialAtPoint(hitPoint.x, hitPoint.z);
+ }
+ });
+
+ const geometryHit = geometryDistance < this.options.maxDrawDistance;
+
+ return new RayTraceResult(geometryHit, hitPoint, normal, material);
+ }
+
+ private processLighting(
+ result: RayTraceResult,
+ direction: Vector,
+ reflectionColour: Colour
+ ): Colour {
+ let diffuseLightIntensity = 0;
+ let specularLightIntensity = 0;
+
+ this.lights.forEach(light => {
+ const lightDirection = light.position
+ .subtract(result.hitPoint)
+ .normalise();
+ const lightDistance = light.position.subtract(result.hitPoint).norm();
+
+ if (!this.isShadowCast(lightDirection, lightDistance, result)) {
+ diffuseLightIntensity +=
+ light.intensity *
+ Math.max(0, lightDirection.dotProduct(result.normal));
+
+ specularLightIntensity +=
+ Math.pow(
+ Math.max(
+ 0,
+ this.calculateReflection(
+ lightDirection,
+ result.normal
+ ).dotProduct(direction)
+ ),
+ result.material.specularExponent
+ ) * light.intensity;
+ }
+ });
+
+ let rgbVector = result.material.diffuseColour.toVector();
+
+ if (this.options.diffuseLighting) {
+ rgbVector = rgbVector.multiply(diffuseLightIntensity);
+ }
+
+ if (this.options.specularLighting) {
+ const totalSpecularIntensity = new Vector(255, 255, 255)
+ .multiply(specularLightIntensity)
+ .multiply(result.material.specularAlbedo);
+
+ rgbVector = rgbVector
+ .multiply(result.material.diffuseAlbedo)
+ .add(totalSpecularIntensity);
+ }
+
+ if (this.options.reflections) {
+ rgbVector = rgbVector.add(
+ reflectionColour.multiply(result.material.reflectionAlbedo)
+ );
+ }
+
+ return Colour.fromVector(rgbVector);
+ }
+
+ private calculateReflection(
+ incidentAngle: Vector,
+ surfaceNormal: Vector
+ ): Vector {
+ // I - N*2.f*(I*N);
+ // v2 = v1 – 2(v1.n)n https://bocilmania.com/2018/04/21/how-to-get-reflection-vector/
+ return incidentAngle.subtract(
+ surfaceNormal
+ .multiply(2)
+ .multiply(incidentAngle.dotProduct(surfaceNormal))
+ );
+ }
+
+ private isShadowCast(
+ lightDirection: Vector,
+ lightDistance: number,
+ result: RayTraceResult
+ ): boolean {
+ if (!this.options.shadows) {
+ return false;
+ }
+
+ let shadowOrigin: Vector;
+
+ if (lightDirection.dotProduct(result.normal) < 0) {
+ shadowOrigin = result.hitPoint.subtract(result.normal.multiply(0.001));
+ } else {
+ shadowOrigin = result.hitPoint.add(result.normal.multiply(0.001));
+ }
+
+ const shadowTrace = this.processSceneGeometry(
+ new Ray(shadowOrigin, lightDirection)
+ );
+
+ return (
+ shadowTrace.geometryHit &&
+ shadowTrace.hitPoint.subtract(shadowOrigin).norm() < lightDistance
+ );
+ }
+
+ private intersectSphere(ray: Ray, sphere: Sphere): RayIntersectionResult {
+ // See https://www.scratchapixel.com/lessons/3d-basic-rendering/minimal-ray-tracer-rendering-simple-shapes/ray-sphere-intersection
+ // for an explanation of the variables
+
+ const l = sphere.centerPoint.subtract(ray.origin);
+ const tca = l.dotProduct(ray.direction);
+ const d2 = l.dotProduct(l) - tca * tca;
+
+ if (d2 > sphere.radius * sphere.radius) {
+ return new RayIntersectionResult(false, 0);
+ }
+
+ const thc = Math.sqrt(sphere.radius * sphere.radius - d2);
+
+ const t0 = tca - thc;
+ const t1 = tca + thc;
+
+ if (t0 >= 0) {
+ return new RayIntersectionResult(true, t0);
+ } else if (t1 >= 0) {
+ return new RayIntersectionResult(true, t1);
+ } else {
+ return new RayIntersectionResult(false, 0);
+ }
+ }
+
+ private intersectPlane(ray: Ray, plane: Plane): RayIntersectionResult {
+ if (Math.abs(ray.direction.y) > 0.001) {
+ const distance = -(ray.origin.y + -plane.yPos) / ray.direction.y;
+ const hitPoint = ray.origin.add(ray.direction.multiply(distance));
+ if (
+ distance > 0 &&
+ Math.abs(hitPoint.x) < plane.width &&
+ hitPoint.z < plane.zStartPos &&
+ hitPoint.z > plane.zEndPos
+ ) {
+ return new RayIntersectionResult(true, distance);
+ } else {
+ return new RayIntersectionResult(false, 0);
+ }
+ } else {
+ return new RayIntersectionResult(false, 0);
+ }
+ }
+}
diff --git a/src/Vector.ts b/src/Vector.ts
new file mode 100644
index 0000000..3cebf6d
--- /dev/null
+++ b/src/Vector.ts
@@ -0,0 +1,36 @@
+export class Vector {
+ constructor(readonly x: number, readonly y: number, readonly z: number) {}
+
+ add(v: Vector): Vector {
+ return new Vector(this.x + v.x, this.y + v.y, this.z + v.z);
+ }
+
+ addScalar(n: number): Vector {
+ return new Vector(this.x + n, this.y + n, this.z + n);
+ }
+
+ subtract(v: Vector): Vector {
+ return new Vector(this.x - v.x, this.y - v.y, this.z - v.z);
+ }
+
+ multiply(n: number): Vector {
+ return new Vector(this.x * n, this.y * n, this.z * n);
+ }
+
+ dotProduct(v: Vector): number {
+ return this.x * v.x + this.y * v.y + this.z * v.z;
+ }
+
+ normalise(): Vector {
+ const vecLength = this.norm();
+ return new Vector(
+ this.x / vecLength,
+ this.y / vecLength,
+ this.z / vecLength
+ );
+ }
+
+ norm(): number {
+ return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z);
+ }
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..f10ef84
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,84 @@
+import {Colour} from './Colour';
+import {Framebuffer} from './Framebuffer';
+import {Plane, Sphere} from './Geometry';
+import {Light} from './Light';
+import {Material} from './Material';
+import {Raytracer, RaytracerOptions} from './Raytracer';
+import {Vector} from './Vector';
+
+function parseOptions(): RaytracerOptions {
+ return {
+ shadows: getInputElement('shadows-toggle').checked,
+ diffuseLighting: getInputElement('diffuse-toggle').checked,
+ specularLighting: getInputElement('specular-toggle').checked,
+ reflections: getInputElement('reflections-toggle').checked,
+ maxRecurseDepth: 5,
+ maxDrawDistance: 1000,
+ };
+}
+
+function initRaytracer(options: RaytracerOptions): Raytracer {
+ const width = 960;
+ const height = 720;
+ const fov = Math.PI / 3;
+
+ const framebuffer = new Framebuffer(width, height);
+
+ const matWhite = new Material(new Colour(102, 102, 77), 0.6, 0.3, 0.1, 50);
+ const matRed = new Material(new Colour(77, 26, 26), 0.9, 0.1, 0.0, 10);
+ const matMirror = new Material(new Colour(193, 193, 193), 0.0, 10, 0.8, 1000);
+ const matGreen= new Material(new Colour(77, 255, 26), 0.3, 0.1, 0.0, 2);
+
+ const spheres = [
+ new Sphere(new Vector(-3, 0, -16), 2, matWhite),
+ new Sphere(new Vector(-1, -1.5, -12), 2, matGreen),
+ new Sphere(new Vector(1.5, -0.5, -18), 3, matRed),
+ new Sphere(new Vector(7, 5, -18), 4, matMirror),
+ ];
+
+ const planes = [
+ new Plane(-4, 10, -10, -30, 0.7)
+ ];
+
+ const lights = [
+ new Light(new Vector(-20, 20, 20), 1.5),
+ new Light(new Vector(30, 50, -25), 1.8),
+ new Light(new Vector(30, 20, 30), 1.7),
+ ];
+
+ return new Raytracer(framebuffer, fov, spheres, planes, lights, options);
+}
+
+function render() {
+ const raytracer = initRaytracer(parseOptions());
+ showLoadingIndicator();
+
+ // Give browser time to repaint
+ setTimeout(() => {
+ const startMs = new Date().getTime();
+ raytracer.render();
+ console.log(`Render took ${new Date().getTime() - startMs}ms`);
+ hideLoadingIndicator();
+ }, 50);
+}
+
+function registerEventListeners() {
+ document.getElementById('render')?.addEventListener('click', render); // TODO disable once clicked
+}
+
+function showLoadingIndicator() {
+ document.getElementById('output-wrapper')?.classList.add('loading');
+}
+
+function hideLoadingIndicator() {
+ document.getElementById('output-wrapper')?.classList.remove('loading');
+}
+
+function getInputElement(elementId: string) {
+ return document.getElementById(elementId) as HTMLInputElement;
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ registerEventListeners();
+ render();
+});