diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Colour.ts | 15 | ||||
| -rw-r--r-- | src/Framebuffer.ts | 38 | ||||
| -rw-r--r-- | src/Geometry.ts | 31 | ||||
| -rw-r--r-- | src/Light.ts | 5 | ||||
| -rw-r--r-- | src/Material.ts | 11 | ||||
| -rw-r--r-- | src/Raytracer.ts | 273 | ||||
| -rw-r--r-- | src/Vector.ts | 36 | ||||
| -rw-r--r-- | src/index.ts | 84 |
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(); +}); |