├── 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 |
--------------------------------------------------------------------------------