├── README.md ├── color.js ├── image.js ├── index.html ├── raytracer.js ├── shape.js └── vector.js /README.md: -------------------------------------------------------------------------------- 1 | Build your own 3D renderer - Javascript 2 | ======================================= 3 | 4 | This repository contains an implementation of the projects outlined in [the Build your own 3D renderer workshop](https://avik-das.github.io/build-your-own-raytracer/). The implementation is in Javascript, using 2D canvas. 5 | 6 | Quick Start 7 | ----------- 8 | 9 | ```sh 10 | git clone https://github.com/avik-das/build-your-own-raytracer-js.git 11 | cd build-your-own-raytracer-js 12 | 13 | # Open in a supported browser 14 | chrome index.html 15 | ``` 16 | 17 | Browser support 18 | --------------- 19 | 20 | The implementation uses a number of ES2016 features, including `let`, arrow functions, and classes, all without any transpilation. A recent enough browser is needed to support these features. 21 | 22 | Tagged milestones 23 | ----------------- 24 | 25 | Each commit of the project implements one of the projects in the workshop. If you wish to implement one of the projects yourself, you can check out the `before-project-N` tag, where `N` is the project number. This will put you in a state just prior to the implementation of that project, with all previous projects implemented: 26 | 27 | ```sh 28 | # Prepare to implement Project 4 29 | git checkout before-project-4 30 | ``` 31 | -------------------------------------------------------------------------------- /color.js: -------------------------------------------------------------------------------- 1 | class Color { 2 | constructor(r, g, b) { 3 | this.r = r; 4 | this.g = g; 5 | this.b = b; 6 | } 7 | 8 | times(other) { 9 | return new Color( 10 | this.r * other.r, 11 | this.g * other.g, 12 | this.b * other.b 13 | ); 14 | } 15 | 16 | scale(scalar) { 17 | return new Color( 18 | this.r * scalar, 19 | this.g * scalar, 20 | this.b * scalar 21 | ); 22 | } 23 | 24 | addInPlace(other) { 25 | this.r += other.r; 26 | this.g += other.g; 27 | this.b += other.b; 28 | } 29 | 30 | clampInPlace() { 31 | this.r = this.r < 0 ? 0 : this.r > 1 ? 1 : this.r; 32 | this.g = this.g < 0 ? 0 : this.g > 1 ? 1 : this.g; 33 | this.b = this.b < 0 ? 0 : this.b > 1 ? 1 : this.b; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /image.js: -------------------------------------------------------------------------------- 1 | class Image { 2 | constructor(w, h) { 3 | this.w = w; 4 | this.h = h; 5 | 6 | this.canvas = this._createCanvas(); 7 | } 8 | 9 | _createCanvas() { 10 | const canvas = document.createElement('canvas'); 11 | canvas.setAttribute('width', this.w); 12 | canvas.setAttribute('height', this.h); 13 | 14 | const context = canvas.getContext('2d'); 15 | const imageData = context.getImageData(0, 0, this.w, this.h); 16 | const pixels = imageData.data; 17 | 18 | return { 19 | canvas, 20 | context, 21 | imageData, 22 | pixels 23 | }; 24 | } 25 | 26 | putPixel(x, y, color) { 27 | const offset = (y * this.w + x) * 4; 28 | this.canvas.pixels[offset ] = color.r | 0; 29 | this.canvas.pixels[offset + 1] = color.g | 0; 30 | this.canvas.pixels[offset + 2] = color.b | 0; 31 | this.canvas.pixels[offset + 3] = 255; 32 | } 33 | 34 | renderInto(elem) { 35 | this 36 | .canvas 37 | .context 38 | .putImageData( 39 | this.canvas.imageData, 40 | 0, 41 | 0 42 | ); 43 | 44 | elem.appendChild(this.canvas.canvas); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /raytracer.js: -------------------------------------------------------------------------------- 1 | const MAX_BOUNCES = 3; 2 | const NUM_SAMPLES_PER_DIRECTION = 2; 3 | const NUM_SAMPLES_PER_PIXEL = 4 | NUM_SAMPLES_PER_DIRECTION * NUM_SAMPLES_PER_DIRECTION; 5 | 6 | class RayTracer { 7 | constructor(scene, w, h) { 8 | this.scene = scene; 9 | this.w = w; 10 | this.h = h; 11 | } 12 | 13 | tracedValueAtPixel(x, y) { 14 | const color = new Color(0, 0, 0); 15 | 16 | for (let dx = 0; dx < NUM_SAMPLES_PER_DIRECTION; dx++) { 17 | for (let dy = 0; dy < NUM_SAMPLES_PER_DIRECTION; dy++) { 18 | const ray = this._rayForPixel( 19 | x + dx / NUM_SAMPLES_PER_DIRECTION, 20 | y + dy / NUM_SAMPLES_PER_DIRECTION 21 | ); 22 | 23 | const sample = this._tracedValueForRay(ray, MAX_BOUNCES); 24 | color.addInPlace(sample.scale(1 / NUM_SAMPLES_PER_PIXEL)); 25 | } 26 | } 27 | 28 | return color; 29 | } 30 | 31 | _tracedValueForRay(ray, depth) { 32 | function min(xs, f) { 33 | if (xs.length == 0) { 34 | return null; 35 | } 36 | 37 | let minValue = Infinity; 38 | let minElement = null; 39 | for (let x of xs) { 40 | const value = f(x); 41 | if (value < minValue) { 42 | minValue = value; 43 | minElement = x; 44 | } 45 | } 46 | 47 | return minElement; 48 | } 49 | 50 | const intersection = min( 51 | this.scene 52 | .objects 53 | .map(obj => { 54 | const t = obj.getIntersection(ray); 55 | if (!t) { return null; } 56 | 57 | let point = ray.at(t); 58 | 59 | return { 60 | object: obj, 61 | t: t, 62 | point: point, 63 | normal: obj.normalAt(point) 64 | }; 65 | }) 66 | .filter(intersection => intersection), 67 | intersection => intersection.t 68 | ); 69 | 70 | if (!intersection) { 71 | return new Color(0, 0, 0); 72 | } 73 | 74 | const color = this._colorAtIntersection(intersection); 75 | 76 | if (depth > 0) { 77 | const v = ray.direction.scale(-1).normalized(); 78 | const r = intersection 79 | .normal 80 | .scale(2) 81 | .scale(intersection.normal.dot(v)) 82 | .minus(v); 83 | const reflectionRay = new Ray( 84 | intersection.point.plus(intersection.normal.scale(0.01)), 85 | r 86 | ); 87 | 88 | const reflected = this._tracedValueForRay(reflectionRay, depth - 1); 89 | color.addInPlace(reflected.times(intersection.object.material.kr)); 90 | } 91 | 92 | return color; 93 | } 94 | 95 | _colorAtIntersection(intersection) { 96 | let color = new Color(0, 0, 0); 97 | const material = intersection.object.material; 98 | 99 | const v = this.scene 100 | .camera 101 | .minus(intersection.point) 102 | .normalized(); 103 | 104 | this.scene 105 | .lights 106 | .forEach(light => { 107 | const l = light 108 | .position 109 | .minus(intersection.point) 110 | .normalized(); 111 | 112 | const lightInNormalDirection = intersection.normal.dot(l); 113 | if (lightInNormalDirection < 0) { 114 | return; 115 | } 116 | 117 | const isShadowed = this._isPointInShadowFromLight( 118 | intersection.point, 119 | intersection.object, 120 | light 121 | ); 122 | if (isShadowed) { 123 | return; 124 | } 125 | 126 | const diffuse = material 127 | .kd 128 | .times(light.id) 129 | .scale(lightInNormalDirection); 130 | color.addInPlace(diffuse); 131 | 132 | const r = intersection 133 | .normal 134 | .scale(2) 135 | .scale(lightInNormalDirection) 136 | .minus(l); 137 | 138 | const amountReflectedAtViewer = v.dot(r); 139 | const specular = material 140 | .ks 141 | .times(light.is) 142 | .scale(Math.pow(amountReflectedAtViewer, material.alpha)); 143 | color.addInPlace(specular); 144 | }); 145 | 146 | const ambient = material 147 | .ka 148 | .times(this.scene.ia); 149 | color.addInPlace(ambient); 150 | 151 | color.clampInPlace(); 152 | return color; 153 | } 154 | 155 | _isPointInShadowFromLight(point, objectToExclude, light) { 156 | const shadowRay = new Ray( 157 | point, 158 | light.position.minus(point) 159 | ); 160 | 161 | for (let i in this.scene.objects) { 162 | const obj = this.scene.objects[i]; 163 | if (obj == objectToExclude) { 164 | continue; 165 | } 166 | 167 | const t = obj.getIntersection(shadowRay); 168 | if (t && t <= 1) { 169 | return true; 170 | } 171 | } 172 | 173 | return false; 174 | } 175 | 176 | _rayForPixel(x, y) { 177 | const xt = x / this.w; 178 | const yt = (this.h - y - 1) / this.h; 179 | 180 | const top = Vector3.lerp( 181 | this.scene.imagePlane.topLeft, 182 | this.scene.imagePlane.topRight, 183 | xt 184 | ); 185 | 186 | const bottom = Vector3.lerp( 187 | this.scene.imagePlane.bottomLeft, 188 | this.scene.imagePlane.bottomRight, 189 | xt 190 | ); 191 | 192 | const point = Vector3.lerp(bottom, top, yt); 193 | return new Ray( 194 | point, 195 | point.minus(this.scene.camera) 196 | ); 197 | } 198 | } 199 | 200 | const WIDTH = 256; 201 | const HEIGHT = 192; 202 | 203 | const SCENE = { 204 | camera: new Vector3(0, 0, 2), 205 | imagePlane: { 206 | topLeft: new Vector3(-1.28, 0.86, -0.5), 207 | topRight: new Vector3(1.28, 0.86, -0.5), 208 | bottomLeft: new Vector3(-1.28, -0.86, -0.5), 209 | bottomRight: new Vector3(1.28, -0.86, -0.5) 210 | }, 211 | ia: new Color(0.5, 0.5, 0.5), 212 | lights: [ 213 | { 214 | position: new Vector3(-3, -0.5, 1), 215 | id: new Color(0.8, 0.3, 0.3), 216 | is: new Color(0.8, 0.8, 0.8) 217 | }, 218 | { 219 | position: new Vector3(3, 2, 1), 220 | id: new Color(0.4, 0.4, 0.9), 221 | is: new Color(0.8, 0.8, 0.8) 222 | } 223 | ], 224 | objects: [ 225 | new Sphere( 226 | new Vector3(-1.1, 0.6, -1), 227 | 0.2, 228 | { 229 | ka: new Color(0.1, 0.1, 0.1), 230 | kd: new Color(0.5, 0.5, 0.9), 231 | ks: new Color(0.7, 0.7, 0.7), 232 | alpha: 20, 233 | kr: new Color(0.1, 0.1, 0.2) 234 | 235 | } 236 | ), 237 | new Sphere( 238 | new Vector3(0.2, -0.1, -1), 239 | 0.5, 240 | { 241 | ka: new Color(0.1, 0.1, 0.1), 242 | kd: new Color(0.9, 0.5, 0.5), 243 | ks: new Color(0.7, 0.7, 0.7), 244 | alpha: 20, 245 | kr: new Color(0.2, 0.1, 0.1) 246 | 247 | } 248 | ), 249 | new Sphere( 250 | new Vector3(1.2, -0.5, -1.75), 251 | 0.4, 252 | { 253 | ka: new Color(0.1, 0.1, 0.1), 254 | kd: new Color(0.1, 0.5, 0.1), 255 | ks: new Color(0.7, 0.7, 0.7), 256 | alpha: 20, 257 | kr: new Color(0.8, 0.9, 0.8) 258 | } 259 | ) 260 | ] 261 | }; 262 | 263 | const image = new Image(WIDTH, HEIGHT); 264 | document.image = image; 265 | 266 | const imageColorFromColor = color => ({ 267 | r: Math.floor(color.r * 255), 268 | g: Math.floor(color.g * 255), 269 | b: Math.floor(color.b * 255) 270 | }); 271 | 272 | const tracer = new RayTracer(SCENE, WIDTH, HEIGHT); 273 | 274 | for (let y = 0; y < HEIGHT; y++) { 275 | for (let x = 0; x < WIDTH; x++) { 276 | image.putPixel( 277 | x, 278 | y, 279 | imageColorFromColor(tracer.tracedValueAtPixel(x, y)) 280 | ); 281 | } 282 | } 283 | 284 | image.renderInto(document.querySelector('body')); 285 | -------------------------------------------------------------------------------- /shape.js: -------------------------------------------------------------------------------- 1 | class Sphere { 2 | constructor(center, radius, material) { 3 | this.center = center; 4 | this.radius = radius; 5 | this.material = material; 6 | } 7 | 8 | getIntersection(ray) { 9 | const cp = ray.origin.minus(this.center); 10 | 11 | const a = ray.direction.dot(ray.direction); 12 | const b = 2 * cp.dot(ray.direction); 13 | const c = cp.dot(cp) - this.radius * this.radius; 14 | 15 | const discriminant = b * b - 4 * a * c; 16 | if (discriminant < 0) { 17 | // no intersection 18 | return null; 19 | } 20 | 21 | const sqrt = Math.sqrt(discriminant); 22 | 23 | const ts = []; 24 | 25 | const sub = (-b - sqrt) / (2 * a); 26 | if (sub >= 0) { 27 | ts.push(sub); 28 | } 29 | 30 | const add = (-b + sqrt) / (2 * a); 31 | if (add >= 0) { 32 | ts.push(add); 33 | } 34 | 35 | if (ts.length == 0) { 36 | return null; 37 | } 38 | 39 | return Math.min.apply(null, ts); 40 | } 41 | 42 | normalAt(point) { 43 | return point.minus(this.center).normalized(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /vector.js: -------------------------------------------------------------------------------- 1 | class Vector3 { 2 | constructor(x, y, z) { 3 | this.x = x; 4 | this.y = y; 5 | this.z = z; 6 | } 7 | 8 | scale(scalar) { 9 | return new Vector3( 10 | this.x * scalar, 11 | this.y * scalar, 12 | this.z * scalar 13 | ); 14 | } 15 | 16 | plus(other) { 17 | return new Vector3( 18 | this.x + other.x, 19 | this.y + other.y, 20 | this.z + other.z 21 | ); 22 | } 23 | 24 | minus(other) { 25 | return new Vector3( 26 | this.x - other.x, 27 | this.y - other.y, 28 | this.z - other.z 29 | ); 30 | } 31 | 32 | dot(other) { 33 | return ( 34 | this.x * other.x + 35 | this.y * other.y + 36 | this.z * other.z 37 | ); 38 | } 39 | 40 | normalized() { 41 | const mag = Math.sqrt(this.dot(this)); 42 | return new Vector3( 43 | this.x / mag, 44 | this.y / mag, 45 | this.z / mag 46 | ); 47 | } 48 | 49 | static lerp(start, end, t) { 50 | return start.scale(1 - t).plus(end.scale(t)); 51 | } 52 | } 53 | 54 | class Ray { 55 | constructor(origin, direction) { 56 | this.origin = origin; 57 | this.direction = direction; 58 | } 59 | 60 | at(t) { 61 | return this.origin.plus(this.direction.scale(t)); 62 | } 63 | } 64 | --------------------------------------------------------------------------------