├── .babelrc
├── .github
└── dependabot.yml
├── .gitignore
├── .node-version
├── README.md
├── now.json
├── package.json
├── public
├── chapters
│ ├── 01.html
│ ├── 02.html
│ ├── 04.html
│ ├── 05.html
│ ├── 06.html
│ ├── 07.html
│ ├── 08.html
│ ├── 09.html
│ ├── 10.html
│ └── 11.html
├── index.html
└── main.css
├── src
├── controllers
│ ├── chapter_01_controller.js
│ ├── chapter_02_controller.js
│ ├── chapter_04_controller.js
│ ├── chapter_05_controller.js
│ ├── chapter_06_controller.js
│ ├── chapter_06_worker.js
│ ├── chapter_07_controller.js
│ ├── chapter_07_worker.js
│ ├── chapter_08_controller.js
│ ├── chapter_08_worker.js
│ ├── chapter_09_controller.js
│ ├── chapter_09_worker.js
│ ├── chapter_10_controller.js
│ ├── chapter_10_worker.js
│ ├── chapter_11_controller.js
│ ├── chapter_11_worker.js
│ ├── chapter_controller.js
│ └── tests_controller.js
├── helpers
│ ├── canvas_helpers.js
│ ├── index.js
│ └── timing_helpers.js
├── index.js
└── models
│ ├── camera.js
│ ├── canvas.js
│ ├── canvas_ppm.js
│ ├── color.js
│ ├── index.js
│ ├── intersection.js
│ ├── intersections.js
│ ├── material.js
│ ├── math.js
│ ├── matrix.js
│ ├── pattern.js
│ ├── patterns
│ ├── checkers.js
│ ├── gradient.js
│ ├── index.js
│ ├── ring.js
│ └── stripe.js
│ ├── point_light.js
│ ├── position.js
│ ├── ray.js
│ ├── shape.js
│ ├── shapes
│ ├── index.js
│ ├── plane.js
│ └── sphere.js
│ ├── tuple.js
│ └── world.js
├── test
├── camera.test.js
├── canvas.test.js
├── intersections.test.js
├── lights.test.js
├── materials.test.js
├── matrices.test.js
├── patterns.test.js
├── planes.test.js
├── rays.test.js
├── shapes.test.js
├── spheres.test.js
├── transformations.test.js
├── tuples.test.js
└── world.test.js
├── webpack.config.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "targets": { "esmodules": true },
7 | "loose": true
8 | }
9 | ]
10 | ],
11 | "plugins": [
12 | [
13 | "@babel/plugin-proposal-class-properties",
14 | {
15 | "loose": true
16 | }
17 | ],
18 | [
19 | "module:faster.js"
20 | ]
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "npm" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "monthly"
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | public/*.js
3 | public/*.js.map
4 | public/tests.txt
5 |
6 | .now
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 12.14.0
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 👋 This is my source code, written in JavaScript, from working through the first eleven chapters of [Jamis Buck](https://github.com/jamis)’s incredible book: _**[The Ray Tracer Challenge](https://pragprog.com/book/jbtracer/the-ray-tracer-challenge)**_.
2 |
3 | > Brace yourself for a fun challenge: build a photorealistic 3D renderer from scratch!
4 | >
5 | > It’s easier than you think. In just a couple of weeks, build a ray tracer that renders beautiful scenes with shadows, reflections, brilliant refraction effects, and subjects composed of various graphics primitives: spheres, cubes, cylinders, triangles, and more.
6 |
7 |
8 | Chapter 11
9 |
10 |
11 |
12 |
13 | Chapter 10
14 |
15 |
16 |
--------------------------------------------------------------------------------
/now.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "name": "ray-tracer-challenge",
4 | "alias": "ray-tracer-challenge.now.sh"
5 | }
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ray-tracer-challenge",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "license": "MIT",
6 | "private": true,
7 | "scripts": {
8 | "build": "webpack -p && yarn test --verbose 2>&1 > public/tests.txt",
9 | "test": "ava",
10 | "test:watch": "ava --watch"
11 | },
12 | "dependencies": {
13 | "@babel/core": "7.21.3",
14 | "@babel/plugin-proposal-class-properties": "7.18.6",
15 | "@babel/preset-env": "7.21.4",
16 | "ava": "3.5.0",
17 | "babel-loader": "8.3.0",
18 | "esm": "3.2.25",
19 | "faster.js": "1.1.1",
20 | "stimulus": "1.1.1",
21 | "webpack": "4.46.0",
22 | "webpack-cli": "3.3.11",
23 | "worker-loader": "2.0.0"
24 | },
25 | "ava": {
26 | "failFast": true,
27 | "cache": false,
28 | "files": [
29 | "test/**/*.test.js"
30 | ],
31 | "require": [
32 | "esm"
33 | ]
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/public/chapters/01.html:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/public/chapters/02.html:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/public/chapters/04.html:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/public/chapters/05.html:
--------------------------------------------------------------------------------
1 |
31 |
--------------------------------------------------------------------------------
/public/chapters/06.html:
--------------------------------------------------------------------------------
1 |
53 |
--------------------------------------------------------------------------------
/public/chapters/07.html:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/chapters/08.html:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/chapters/09.html:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/chapters/10.html:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/chapters/11.html:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
15 | Chapter 1: Tuples, Points, and Vectors
16 |
17 |
18 |
19 |
23 | Chapter 2: Drawing on a Canvas
24 |
25 |
26 |
27 |
31 | Chapter 4: Matrix Transformations
32 |
33 |
34 |
35 |
39 | Chapter 5: Ray/Sphere Intersections
40 |
41 |
42 |
43 |
47 | Chapter 6: Light and Shading
48 |
49 |
50 |
51 |
55 | Chapter 7: Making a Scene
56 |
57 |
58 |
59 |
63 | Chapter 8: Shadows
64 |
65 |
66 |
67 |
71 | Chapter 9: Planes
72 |
73 |
74 |
75 |
79 | Chapter 10: Patterns
80 |
81 |
82 |
83 |
87 | Chapter 11: Reflection and Refraction
88 |
89 |
90 |
91 |
92 |
93 |
96 | Tests
97 |
98 |
99 |
100 |
104 |
105 |
106 |
--------------------------------------------------------------------------------
/public/main.css:
--------------------------------------------------------------------------------
1 | * {
2 | font-family: "SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace;
3 | font-size: 12px;
4 | margin: 0;
5 | padding: 0;
6 | box-sizing: border-box;
7 | }
8 |
9 | body {
10 | padding: 1.5em;
11 | display: flex;
12 | flex-direction: column;
13 | min-height: 100vh;
14 | }
15 |
16 | main {
17 | flex: 1 0 auto;
18 | }
19 |
20 | footer {
21 | flex-shrink: 0;
22 | font-size: 11px;
23 | text-align: center;
24 | margin: 3em 1em 0 1em;
25 | color: #525252;
26 | }
27 |
28 | a {
29 | color: inherit;
30 | }
31 |
32 | a:active,
33 | a:hover {
34 | color: inherit;
35 | outline: 0;
36 | }
37 |
38 | details, form, p {
39 | margin-bottom: 1em;
40 | }
41 |
42 | details > *:not(summary) {
43 | padding: 1em;
44 | }
45 |
46 | details:not([open]) {
47 | opacity: 0.4;
48 | }
49 |
50 | details summary {
51 | outline: none;
52 | cursor: zoom-in;
53 | }
54 |
55 | details[open] summary {
56 | font-weight: bold;
57 | cursor: zoom-out;
58 | }
59 |
60 | hr {
61 | border: none;
62 | margin: 8em 0 0;
63 | }
64 |
65 | ul, ol, li {
66 | margin-left: 1em;
67 | }
68 |
69 | fieldset {
70 | display: inline-block;
71 | vertical-align: top;
72 | margin: 0 0.5em 0.5em 0;
73 | padding: 0.3em 0.6em;
74 | border: 1px solid #666;
75 | border-radius: 3px;
76 | }
77 |
78 | legend {
79 | color: #333;
80 | }
81 |
82 | button {
83 | font-size: 11px;
84 | padding: 2px 8px 3px 8px;
85 | }
86 |
--------------------------------------------------------------------------------
/src/controllers/chapter_01_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "stimulus"
2 |
3 | export default class extends Controller {
4 | static targets = [ "speedInput", "positionList" ]
5 |
6 | run(event) {
7 | event.preventDefault()
8 | this.reset()
9 | this.play()
10 | }
11 |
12 | // Private
13 |
14 | reset() {
15 | this.world = {
16 | gravity: Vector(0, -0.1, 0),
17 | wind: Vector(-0.01, 0, 0)
18 | }
19 |
20 | this.projectile = {
21 | position: Point(1, 1, 0),
22 | velocity: Vector(1, 1, 0).normalize.multiplyBy(this.speed)
23 | }
24 |
25 | this.positionListTarget.innerHTML = ""
26 | }
27 |
28 | play() {
29 | requestAnimationFrame(() => {
30 | this.renderProjectile()
31 | this.tick()
32 | if (this.projectile.position.y > 0) {
33 | this.play()
34 | } else {
35 | this.renderProjectile()
36 | }
37 | })
38 | }
39 |
40 | tick() {
41 | const { gravity, wind } = this.world
42 | const { position, velocity } = this.projectile
43 | this.projectile = {
44 | position: position.add(velocity),
45 | velocity: velocity.add(gravity).add(wind)
46 | }
47 | }
48 |
49 | renderProjectile() {
50 | const { x, y } = this.projectile.position
51 | const html = `x: ${format(x)} y: ${format(y)}`
52 | this.positionListTarget.insertAdjacentHTML("beforeend", html)
53 | }
54 |
55 | get speed() {
56 | return parseFloat(this.speedInputTarget.value)
57 | }
58 | }
59 |
60 | function format(number, precision = 12) {
61 | return number.toPrecision(precision).slice(0, precision).padEnd(precision, 0)
62 | }
63 |
--------------------------------------------------------------------------------
/src/controllers/chapter_02_controller.js:
--------------------------------------------------------------------------------
1 | import { nextFrame, nextIdle, DOMCanvasProxy } from "../helpers"
2 | import { Controller } from "stimulus"
3 |
4 | export default class extends Controller {
5 | static targets = [ "speedInput", "preview" ]
6 |
7 | connect() {
8 | this.render()
9 | }
10 |
11 | async render() {
12 | await nextIdle()
13 | const { element } = this.canvas
14 |
15 | await nextFrame()
16 | this.previewTarget.innerHTML = ""
17 | this.previewTarget.appendChild(element)
18 | }
19 |
20 | async download(event) {
21 | const blob = this.canvas.toPPM().toBlob()
22 | const url = URL.createObjectURL(blob)
23 | event.currentTarget.href = url
24 |
25 | await nextFrame()
26 | URL.revokeObjectURL(url)
27 | }
28 |
29 | get canvas() {
30 | const canvas = new DOMCanvasProxy(900, 550)
31 | const red = Color.of(1.5, 0, 0)
32 |
33 | for (let { x, y } of positions(this.speed)) {
34 | if (canvas.hasPixelAt(x, y)) {
35 | y = Math.abs(canvas.height - y)
36 | canvas.writePixel(x, y, red)
37 | }
38 | }
39 |
40 | return canvas
41 | }
42 |
43 | get speed() {
44 | return parseFloat(this.speedInputTarget.value)
45 | }
46 | }
47 |
48 | function *positions(speed) {
49 | const gravity = Vector(0, -0.1, 0)
50 | const wind = Vector(-0.01, 0, 0)
51 |
52 | let position = Point(0, 1, 0)
53 | let velocity = Vector(1, 1.8, 0).normalize.multiplyBy(speed)
54 |
55 | while (position.y > 0) {
56 | yield {
57 | x: Math.floor(position.x),
58 | y: Math.floor(position.y)
59 | }
60 | position = position.add(velocity)
61 | velocity = velocity.add(gravity).add(wind)
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/controllers/chapter_04_controller.js:
--------------------------------------------------------------------------------
1 | import { nextFrame, nextIdle, DOMCanvasProxy } from "../helpers"
2 | import { Controller } from "stimulus"
3 |
4 | export default class extends Controller {
5 | static targets = [ "sizeInput", "radiusInput", "preview" ]
6 |
7 | connect() {
8 | this.render()
9 | }
10 |
11 | async render() {
12 | await nextIdle()
13 | const { element } = this.canvas
14 |
15 | await nextFrame()
16 | this.previewTarget.innerHTML = ""
17 | this.previewTarget.appendChild(element)
18 | }
19 |
20 | async download(event) {
21 | const blob = this.canvas.toPPM().toBlob()
22 | const url = URL.createObjectURL(blob)
23 | event.currentTarget.href = url
24 |
25 | await nextFrame()
26 | URL.revokeObjectURL(url)
27 | }
28 |
29 | get canvas() {
30 | const { size, radius } = this
31 | const canvas = new DOMCanvasProxy(size, size)
32 | const color = Color.of(0, 1, 0)
33 | const start = Point(0, 1, 0)
34 |
35 | for (let hour = 1; hour <= 12; hour++) {
36 | const rotation = Matrix.rotationZ(hour * Math.PI / 6)
37 | const position = rotation.multiplyBy(start)
38 |
39 | const x = Math.round(position.x * radius + size / 2)
40 | const y = Math.round(position.y * radius + size / 2)
41 |
42 | canvas.writePixel(x, y, color)
43 | canvas.writePixel(x + 1, y, color)
44 | canvas.writePixel(x + 1, y + 1, color)
45 | canvas.writePixel(x, y + 1, color)
46 | }
47 |
48 | return canvas
49 | }
50 |
51 | get size() {
52 | return parseInt(this.sizeInputTarget.value)
53 | }
54 |
55 | get radius() {
56 | return parseInt(this.radiusInputTarget.value)
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/controllers/chapter_05_controller.js:
--------------------------------------------------------------------------------
1 | import { nextFrame, nextIdle, DOMCanvasProxy } from "../helpers"
2 | import { Controller } from "stimulus"
3 |
4 | export default class extends Controller {
5 | static targets = [ "transformInput", "preview" ]
6 |
7 | connect() {
8 | this.render()
9 | }
10 |
11 | async render() {
12 | await nextIdle()
13 | const { element } = this.canvas
14 |
15 | await nextFrame()
16 | this.previewTarget.innerHTML = ""
17 | this.previewTarget.appendChild(element)
18 | }
19 |
20 | async download(event) {
21 | const blob = this.canvas.toPPM().toBlob()
22 | const url = URL.createObjectURL(blob)
23 | event.currentTarget.href = url
24 |
25 | await nextFrame()
26 | URL.revokeObjectURL(url)
27 | }
28 |
29 | get canvas() {
30 | const rayOrigin = Point(0, 0, -5)
31 |
32 | const sphere = Sphere.create({ transform: this.transform })
33 |
34 | const wallZ = 10
35 | const wallSize = 7.0
36 |
37 | const canvasSize = 150
38 | const pixelSize = wallSize / canvasSize
39 | const halfSize = wallSize / 2
40 |
41 | const canvas = new DOMCanvasProxy(canvasSize, canvasSize)
42 | const color = Color.of(0, 0, 1)
43 |
44 | for (const { x, y } of canvas) {
45 | const worldX = -halfSize + pixelSize * x
46 | const worldY = halfSize - pixelSize * y
47 | const position = Point(worldX, worldY, wallZ)
48 | const ray = new Ray(rayOrigin, position.subtract(rayOrigin))
49 | const { hit } = ray.intersect(sphere)
50 | if (hit) {
51 | canvas.writePixel(x, y, color)
52 | }
53 | }
54 |
55 | return canvas
56 | }
57 |
58 | get transform() {
59 | return this.transforms.reduce((result, value) => result.multiplyBy(value))
60 | }
61 |
62 | get transforms() {
63 | const inputs = this.transformInputTargets.filter(e => e.checked)
64 | const transforms = inputs.map(e => TRANSFORMS[e.value])
65 | return [Matrix.identity, ...transforms]
66 | }
67 | }
68 |
69 | const TRANSFORMS = {
70 | shrinkY: Matrix.scaling(1, 0.5, 1),
71 | shrinkX: Matrix.scaling(0.5, 1, 1),
72 | rotate: Matrix.rotationZ(Math.PI / 4),
73 | skew: Matrix.shearing(1, 0, 0, 0, 0, 0)
74 | }
75 |
--------------------------------------------------------------------------------
/src/controllers/chapter_06_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "stimulus"
2 | import { nextFrame, nextIdle, DOMCanvasProxy } from "../helpers"
3 | import Worker from "./chapter_06_worker"
4 |
5 | const CANVAS_SIZE = 175 * window.devicePixelRatio
6 | const PIXEL_COUNT = CANVAS_SIZE * CANVAS_SIZE
7 | const COLOR = Color.of(1, 0.2, 1)
8 | const WORKER_COUNT = navigator.hardwareConcurrency || 2
9 |
10 | export default class extends Controller {
11 | static targets = [ "transformInput", "ambientInput", "diffuseInput", "specularInput", "shininessInput", "preview", "stats" ]
12 |
13 | connect() {
14 | this.workers = Array.from({ length: WORKER_COUNT }, _ => new Worker)
15 | this.render()
16 | }
17 |
18 | disconnect() {
19 | this.workers.forEach(worker => worker.terminate())
20 | }
21 |
22 | // Actions
23 |
24 | async render() {
25 | await nextFrame()
26 | this.canvas = new DOMCanvasProxy(CANVAS_SIZE, CANVAS_SIZE)
27 |
28 | this.previewTarget.innerHTML = ""
29 | this.previewTarget.appendChild(this.canvas.element)
30 |
31 | await nextIdle()
32 | const stats = await this.writePixels()
33 | const { format } = new Intl.NumberFormat
34 | this.statsTarget.innerHTML = [
35 | `Rendered in ${format(stats.time)}ms using ${WORKER_COUNT} web workers.`,
36 | `Hit ${format(stats.pixelWriteCount)} out of ${format(PIXEL_COUNT)} pixels.`
37 | ].join("
")
38 | }
39 |
40 | async download(event) {
41 | const blob = this.canvas.toPPM().toBlob()
42 | const url = URL.createObjectURL(blob)
43 | event.currentTarget.href = url
44 |
45 | await nextFrame()
46 | URL.revokeObjectURL(url)
47 | }
48 |
49 | // Private
50 |
51 | writePixels() {
52 | return new Promise(resolve => {
53 | const startTime = performance.now()
54 |
55 | const { transform, ambient, diffuse, specular, shininess } = this
56 | const message = { canvasSize: CANVAS_SIZE, color: COLOR, transform, ambient, diffuse, specular, shininess }
57 | const batchSize = Math.floor(CANVAS_SIZE / WORKER_COUNT)
58 |
59 | let completedWorkerCount = 0
60 | let pixelWriteCount = 0
61 |
62 | this.workers.forEach((worker, index) => {
63 | const start = index * batchSize
64 | const end = start + batchSize
65 | worker.postMessage({ start, end, ...message })
66 |
67 | worker.onmessage = ({ data }) => {
68 | if (data.pixels) {
69 | data.pixels.forEach(({ x, y, color }) => {
70 | this.canvas.writePixel(x, y, Color.of(...color))
71 | pixelWriteCount++
72 | })
73 | } else {
74 | completedWorkerCount++
75 | if (completedWorkerCount == WORKER_COUNT) {
76 | const time = performance.now() - startTime
77 | resolve({ time, pixelWriteCount })
78 | }
79 | }
80 | }
81 | })
82 | })
83 | }
84 |
85 | get transform() {
86 | return this.transforms.reduce((result, value) => result.multiplyBy(value))
87 | }
88 |
89 | get transforms() {
90 | const inputs = this.transformInputTargets.filter(e => e.checked)
91 | const transforms = inputs.map(e => TRANSFORMS[e.value])
92 | return [Matrix.identity, ...transforms]
93 | }
94 |
95 | get ambient() {
96 | return parseFloat(this.ambientInputTarget.value)
97 | }
98 |
99 | get diffuse() {
100 | return parseFloat(this.diffuseInputTarget.value)
101 | }
102 |
103 | get specular() {
104 | return parseFloat(this.specularInputTarget.value)
105 | }
106 |
107 | get shininess() {
108 | return parseInt(this.shininessInputTarget.value)
109 | }
110 | }
111 |
112 | const TRANSFORMS = {
113 | shrinkY: Matrix.scaling(1, 0.5, 1),
114 | shrinkX: Matrix.scaling(0.5, 1, 1),
115 | rotate: Matrix.rotationZ(Math.PI / 4),
116 | skew: Matrix.shearing(1, 0, 0, 0, 0, 0)
117 | }
118 |
--------------------------------------------------------------------------------
/src/controllers/chapter_06_worker.js:
--------------------------------------------------------------------------------
1 | onmessage = ({ data }) => {
2 | const sphere = Sphere.create({
3 | color: Color.from(data.color),
4 | ambient: data.ambient,
5 | diffuse: data.diffuse,
6 | specular: data.specular,
7 | shininess: data.shininess,
8 | transform: Matrix.from(data.transform),
9 | })
10 |
11 | let { start, end } = data
12 | function sendBatch() {
13 | postMessage({ pixels: getPixels(sphere, data.canvasSize, start, ++start) })
14 |
15 | if (start < end) {
16 | setTimeout(sendBatch)
17 | } else {
18 | postMessage({}) // Done
19 | }
20 | }
21 | sendBatch()
22 | }
23 |
24 | function getPixels(sphere, canvasSize, start, end) {
25 | const lightPosition = Point(-10, 10, -10)
26 | const lightColor = Color.WHITE
27 | const light = new PointLight(lightPosition, lightColor)
28 |
29 | const rayOrigin = Point(0, 0, -5)
30 |
31 | const wallZ = 10
32 | const wallSize = 7.0
33 |
34 | const pixelSize = wallSize / canvasSize
35 | const halfSize = wallSize / 2
36 |
37 | const pixels = []
38 | for (let y = 0; y < canvasSize; y++) {
39 | const worldY = halfSize - pixelSize * y
40 | for (let x = start; x < end; x++) {
41 | const worldX = -halfSize + pixelSize * x
42 | const position = Point(worldX, worldY, wallZ)
43 | const ray = new Ray(rayOrigin, position.subtract(rayOrigin).normalize)
44 | const { hit } = ray.intersect(sphere)
45 | if (hit) {
46 | const point = ray.position(hit.t)
47 | const normalv = sphere.normalAt(point)
48 | const eyev = ray.direction.negate
49 | const color = hit.object.material.lighting({ light, point, eyev, normalv })
50 | pixels.push({ x, y, color })
51 | }
52 | }
53 | }
54 | return pixels
55 | }
56 |
--------------------------------------------------------------------------------
/src/controllers/chapter_07_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "stimulus"
2 | import { nextFrame, DOMCanvasProxy } from "../helpers"
3 | import Worker from "./chapter_07_worker"
4 |
5 | const HSIZE = 250 * window.devicePixelRatio
6 | const VSIZE = 150 * window.devicePixelRatio
7 | const PIXEL_COUNT = HSIZE * VSIZE
8 | const WORKER_COUNT = navigator.hardwareConcurrency || 4
9 |
10 | export default class extends Controller {
11 | static targets = [ "preview", "stats" ]
12 |
13 | connect() {
14 | this.workers = Array.from({ length: WORKER_COUNT }, _ => new Worker)
15 | this.render()
16 | }
17 |
18 | disconnect() {
19 | this.workers.forEach(worker => worker.terminate())
20 | }
21 |
22 | // Actions
23 |
24 | async render() {
25 | await nextFrame()
26 | this.canvas = new DOMCanvasProxy(HSIZE, VSIZE)
27 | this.previewTarget.innerHTML = ""
28 | this.previewTarget.appendChild(this.canvas.element)
29 |
30 | await nextFrame()
31 | const stats = await this.writePixels()
32 |
33 | await nextFrame()
34 | const { format } = new Intl.NumberFormat
35 | this.statsTarget.style.width = this.canvas.element.style.width
36 | this.statsTarget.textContent = `
37 | Rendered ${format(PIXEL_COUNT)} pixels
38 | in ${format(stats.time)}ms
39 | using ${WORKER_COUNT} web workers.
40 | `
41 | }
42 |
43 | async download(event) {
44 | const blob = this.canvas.toPPM().toBlob()
45 | const url = URL.createObjectURL(blob)
46 | event.currentTarget.href = url
47 |
48 | await nextFrame()
49 | URL.revokeObjectURL(url)
50 | }
51 |
52 | // Private
53 |
54 | writePixels() {
55 | return new Promise(resolve => {
56 | const startTime = performance.now()
57 | const message = { hsize: HSIZE, vsize: VSIZE }
58 | const batchSize = Math.floor(HSIZE / WORKER_COUNT)
59 | let completedWorkerCount = 0
60 |
61 | this.workers.forEach((worker, index) => {
62 | const startX = index * batchSize
63 | const endX = startX + batchSize
64 | worker.postMessage({ startX, endX, ...message })
65 |
66 | worker.onmessage = ({ data }) => {
67 | if (data.pixels) {
68 | data.pixels.forEach(({ x, y, color }) => {
69 | this.canvas.writePixel(x, y, Color.of(...color))
70 | })
71 | } else {
72 | completedWorkerCount++
73 | if (completedWorkerCount == WORKER_COUNT) {
74 | const time = performance.now() - startTime
75 | resolve({ time })
76 | }
77 | }
78 | }
79 | })
80 | })
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/controllers/chapter_07_worker.js:
--------------------------------------------------------------------------------
1 | onmessage = ({ data }) => {
2 | const scene = new Scene(data.hsize, data.vsize)
3 |
4 | let { startX, endX } = data
5 | async function sendBatch() {
6 | await nextFrame()
7 | postMessage({ pixels: scene.getPixels(startX, ++startX) })
8 |
9 | if (startX < endX) {
10 | sendBatch()
11 | } else {
12 | postMessage({}) // Done
13 | }
14 | }
15 | sendBatch()
16 | }
17 |
18 | class Scene {
19 | constructor(hsize, vsize) {
20 | this.hsize = hsize
21 | this.vsize = vsize
22 | }
23 |
24 | getPixels(startX, endX) {
25 | return [...this.camera.pixelsForWorld(this.world, startX, endX)]
26 | }
27 |
28 | get camera() {
29 | return Camera.create({
30 | hsize: this.hsize,
31 | vsize: this.vsize,
32 | view: Math.PI / 3,
33 | transform: Matrix.viewTransform(
34 | Point(0, 1.5, -5),
35 | Point(0, 1, 0),
36 | Vector(0, 1, 0)
37 | )
38 | })
39 | }
40 |
41 | get world() {
42 | const world = World.of(
43 | this.floor,
44 | this.leftWall,
45 | this.rightWall,
46 | this.leftSphere,
47 | this.middleSphere,
48 | this.rightSphere
49 | )
50 | world.light = new PointLight(Point(-10, 10, -10), Color.WHITE)
51 | return world
52 | }
53 |
54 | get floor() {
55 | return Sphere.create({
56 | color: Color.of(1, 0.9, 0.9),
57 | specular: 0,
58 | transform: Matrix.scaling(10, 0.01, 10)
59 | })
60 | }
61 |
62 | get leftWall() {
63 | return Sphere.create({
64 | color: Color.of(1, 0.9, 0.9),
65 | specular: 0,
66 | transform: Matrix.translation(0, 0, 5)
67 | .multiplyBy( Matrix.rotationY(-Math.PI / 4) )
68 | .multiplyBy( Matrix.rotationX(Math.PI / 2) )
69 | .multiplyBy( Matrix.scaling(10, 0.01, 10) )
70 | })
71 | }
72 |
73 | get rightWall() {
74 | return Sphere.create({
75 | color: Color.of(1, 0.9, 0.9),
76 | specular: 0,
77 | transform: Matrix.translation(0, 0, 5)
78 | .multiplyBy( Matrix.rotationY(Math.PI / 4) )
79 | .multiplyBy( Matrix.rotationX(Math.PI / 2) )
80 | .multiplyBy( Matrix.scaling(10, 0.01, 10) )
81 | })
82 | }
83 |
84 | get leftSphere() {
85 | return Sphere.create({
86 | color: Color.of(1, 0.8, 0.1),
87 | diffuse: 0.7,
88 | specular: 0.3,
89 | transform: Matrix.translation(-1.5, 0.33, -0.75)
90 | .multiplyBy( Matrix.scaling(0.33, 0.33, 0.33) )
91 | })
92 | }
93 |
94 | get middleSphere() {
95 | return Sphere.create({
96 | color: Color.of(0.1, 1, 0.5),
97 | diffuse: 0.7,
98 | specular: 0.3,
99 | transform: Matrix.translation(-0.5, 1, 0.5)
100 | })
101 | }
102 |
103 | get rightSphere() {
104 | return Sphere.create({
105 | color: Color.of(0.5, 1, 0.1),
106 | diffuse: 0.7,
107 | specular: 0.3,
108 | transform: Matrix.translation(1.5, 0.5, -0.5)
109 | .multiplyBy( Matrix.scaling(0.5, 0.5, 0.5) )
110 | })
111 | }
112 | }
113 |
114 | async function nextFrame() {
115 | return new Promise(resolve => {
116 | self.requestAnimationFrame
117 | ? requestAnimationFrame(resolve)
118 | : setTimeout(resolve, 17)
119 | })
120 | }
121 |
--------------------------------------------------------------------------------
/src/controllers/chapter_08_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "stimulus"
2 | import { nextFrame, DOMCanvasProxy } from "../helpers"
3 | import Worker from "./chapter_08_worker"
4 |
5 | const HSIZE = 240 * window.devicePixelRatio
6 | const VSIZE = 160 * window.devicePixelRatio
7 | const PIXEL_COUNT = HSIZE * VSIZE
8 | const WORKER_COUNT = navigator.hardwareConcurrency || 4
9 |
10 | export default class extends Controller {
11 | static targets = [ "preview", "stats" ]
12 |
13 | connect() {
14 | this.workers = Array.from({ length: WORKER_COUNT }, _ => new Worker)
15 | this.render()
16 | }
17 |
18 | disconnect() {
19 | this.workers.forEach(worker => worker.terminate())
20 | }
21 |
22 | // Actions
23 |
24 | async render() {
25 | await nextFrame()
26 | this.canvas = new DOMCanvasProxy(HSIZE, VSIZE)
27 | this.previewTarget.innerHTML = ""
28 | this.previewTarget.appendChild(this.canvas.element)
29 |
30 | await nextFrame()
31 | const stats = await this.writePixels()
32 |
33 | await nextFrame()
34 | const { format } = new Intl.NumberFormat
35 | this.statsTarget.style.width = this.canvas.element.style.width
36 | this.statsTarget.textContent = `
37 | Rendered ${format(PIXEL_COUNT)} pixels
38 | in ${format(stats.time)}ms
39 | using ${WORKER_COUNT} web workers.
40 | `
41 | }
42 |
43 | async download(event) {
44 | const blob = this.canvas.toPPM().toBlob()
45 | const url = URL.createObjectURL(blob)
46 | event.currentTarget.href = url
47 |
48 | await nextFrame()
49 | URL.revokeObjectURL(url)
50 | }
51 |
52 | // Private
53 |
54 | writePixels() {
55 | return new Promise(resolve => {
56 | const startTime = performance.now()
57 | const message = { hsize: HSIZE, vsize: VSIZE }
58 | const batchSize = Math.floor(HSIZE / WORKER_COUNT)
59 | let completedWorkerCount = 0
60 |
61 | this.workers.forEach((worker, index) => {
62 | const startX = index * batchSize
63 | const endX = startX + batchSize
64 | worker.postMessage({ startX, endX, ...message })
65 |
66 | worker.onmessage = ({ data }) => {
67 | if (data.pixels) {
68 | data.pixels.forEach(({ x, y, color }) => {
69 | this.canvas.writePixel(x, y, Color.of(...color))
70 | })
71 | } else {
72 | completedWorkerCount++
73 | if (completedWorkerCount == WORKER_COUNT) {
74 | const time = performance.now() - startTime
75 | resolve({ time })
76 | }
77 | }
78 | }
79 | })
80 | })
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/controllers/chapter_08_worker.js:
--------------------------------------------------------------------------------
1 | onmessage = ({ data }) => {
2 | const scene = new Scene(data.hsize, data.vsize)
3 |
4 | let { startX, endX } = data
5 | async function sendBatch() {
6 | await nextFrame()
7 | postMessage({ pixels: scene.getPixels(startX, ++startX) })
8 |
9 | if (startX < endX) {
10 | sendBatch()
11 | } else {
12 | postMessage({}) // Done
13 | }
14 | }
15 | sendBatch()
16 | }
17 |
18 | const FLOOR_COLOR = Color.of(1, 1, 0.92)
19 |
20 | class Scene {
21 | constructor(hsize, vsize) {
22 | this.hsize = hsize
23 | this.vsize = vsize
24 | }
25 |
26 | getPixels(startX, endX) {
27 | return [...this.camera.pixelsForWorld(this.world, startX, endX)]
28 | }
29 |
30 | get camera() {
31 | return Camera.create({
32 | hsize: this.hsize,
33 | vsize: this.vsize,
34 | view: Math.PI / 3,
35 | transform: Matrix.viewTransform(
36 | Point(-3, 1.5, -6),
37 | Point(-1, 1.5, 0),
38 | Vector(0, 1, 0)
39 | )
40 | })
41 | }
42 |
43 | get world() {
44 | const world = World.of(
45 | this.floor,
46 | this.leftWall,
47 | this.rightWall,
48 | this.leftSphere,
49 | this.topSphere,
50 | this.middleSphere,
51 | this.rightSphere
52 | )
53 | world.light = new PointLight(Point(-10, 8, -10), Color.WHITE)
54 | return world
55 | }
56 |
57 | get floor() {
58 | return Sphere.create({
59 | color: FLOOR_COLOR,
60 | specular: 0,
61 | transform: Matrix.scaling(10, 0.01, 10)
62 | })
63 | }
64 |
65 | get leftWall() {
66 | return Sphere.create({
67 | color: FLOOR_COLOR,
68 | specular: 0,
69 | transform: Matrix.translation(0, 0, 5)
70 | .multiplyBy( Matrix.rotationY(-Math.PI / 4) )
71 | .multiplyBy( Matrix.rotationX(Math.PI / 2) )
72 | .multiplyBy( Matrix.scaling(10, 0.01, 10) )
73 | })
74 | }
75 |
76 | get rightWall() {
77 | return Sphere.create({
78 | color: FLOOR_COLOR,
79 | specular: 0,
80 | transform: Matrix.translation(0, 0, 5)
81 | .multiplyBy( Matrix.rotationY(Math.PI / 4) )
82 | .multiplyBy( Matrix.rotationX(Math.PI / 2) )
83 | .multiplyBy( Matrix.scaling(10, 0.01, 10) )
84 | })
85 | }
86 |
87 | get leftSphere() {
88 | return Sphere.create({
89 | color: Color.of(1.2, 0, 0),
90 | diffuse: 0.7,
91 | specular: 0.3,
92 | transform: Matrix.translation(-2.9, 1.6, -1.8)
93 | .multiplyBy( Matrix.rotationZ(Math.PI / 3) )
94 | .multiplyBy( Matrix.scaling(0.75, 0.75, 0.75) )
95 | .multiplyBy( Matrix.shearing(0.95, 0, 0, 0, 0, 0) )
96 | })
97 | }
98 |
99 | get topSphere() {
100 | return Sphere.create({
101 | color: Color.of(1.2, 0.1, 0.1),
102 | diffuse: 0.7,
103 | specular: 0.3,
104 | transform: Matrix.translation(-1, 2.4, -1)
105 | .multiplyBy( Matrix.rotationZ(Math.PI / 1.4) )
106 | .multiplyBy( Matrix.scaling(0.4, 0.4, 0.4) )
107 | .multiplyBy( Matrix.shearing(0.8, 0, 0, 0, 0, 0) )
108 |
109 | })
110 | }
111 |
112 | get middleSphere() {
113 | return Sphere.create({
114 | color: Color.of(0.1, 1, 0.5),
115 | diffuse: 0.7,
116 | specular: 0.3,
117 | transform: Matrix.translation(-0.2, 1, 0.5)
118 | })
119 | }
120 |
121 | get rightSphere() {
122 | return Sphere.create({
123 | color: Color.of(0.5, 0.5, 1),
124 | diffuse: 0.7,
125 | specular: 0.3,
126 | transform: Matrix.translation(1.5, 0.5, -0.5)
127 | .multiplyBy( Matrix.scaling(0.5, 0.5, 0.5) )
128 | })
129 | }
130 | }
131 |
132 | async function nextFrame() {
133 | return new Promise(resolve => {
134 | self.requestAnimationFrame
135 | ? requestAnimationFrame(resolve)
136 | : setTimeout(resolve, 17)
137 | })
138 | }
139 |
--------------------------------------------------------------------------------
/src/controllers/chapter_09_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "stimulus"
2 | import { nextFrame, createCanvasElement } from "../helpers"
3 | import Worker from "./chapter_09_worker"
4 |
5 | const WIDTH = 240 * window.devicePixelRatio
6 | const HEIGHT = 160 * window.devicePixelRatio
7 | const PIXEL_COUNT = WIDTH * HEIGHT
8 | const WORKER_COUNT = navigator.hardwareConcurrency || 4
9 |
10 | export default class extends Controller {
11 | static targets = [ "preview", "stats" ]
12 |
13 | connect() {
14 | this.workers = Array.from({ length: WORKER_COUNT }, _ => new Worker)
15 | this.render()
16 | }
17 |
18 | disconnect() {
19 | this.workers.forEach(worker => worker.terminate())
20 | }
21 |
22 | // Actions
23 |
24 | async render() {
25 | const element = createCanvasElement(WIDTH, HEIGHT)
26 |
27 | await nextFrame()
28 | this.previewTarget.innerHTML = ""
29 | this.previewTarget.appendChild(element)
30 |
31 | await nextFrame()
32 | const stats = await this.writePixels(element)
33 |
34 | await nextFrame()
35 | const { format } = new Intl.NumberFormat
36 | this.statsTarget.style.width = element.style.width
37 | this.statsTarget.textContent = `
38 | Rendered ${format(PIXEL_COUNT)} pixels
39 | in ${format(stats.time)}ms
40 | using ${WORKER_COUNT} web workers.
41 | `
42 | }
43 |
44 | // Private
45 |
46 | writePixels(canvas) {
47 | return new Promise(resolve => {
48 | const startTime = performance.now()
49 | const context = canvas.getContext("2d")
50 | const batchSize = Math.floor(WIDTH / WORKER_COUNT)
51 | let completedWorkerCount = 0
52 |
53 | this.workers.forEach((worker, index) => {
54 | const startX = index * batchSize
55 | const endX = startX + batchSize
56 | worker.postMessage({ startX, endX, width: WIDTH, height: HEIGHT })
57 |
58 | worker.onmessage = async ({ data }) => {
59 | if (data.colors) {
60 | const colors = new Uint8ClampedArray(data.colors)
61 | const imageData = new ImageData(colors, data.width, HEIGHT)
62 | await nextFrame()
63 | context.putImageData(imageData, data.x, 0)
64 | } else {
65 | completedWorkerCount++
66 | if (completedWorkerCount == WORKER_COUNT) {
67 | const time = performance.now() - startTime
68 | resolve({ time })
69 | }
70 | }
71 | }
72 | })
73 | })
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/controllers/chapter_09_worker.js:
--------------------------------------------------------------------------------
1 | onmessage = ({ data }) => {
2 | const scene = new Scene(data.width, data.height)
3 | let { startX, endX } = data
4 |
5 | function sendBatch() {
6 | const x = startX
7 | const width = Math.min(x + 10, endX) - x
8 | startX += width
9 |
10 | const colors = scene.getColorData(x, width).buffer
11 | postMessage({ x, width, colors }, [ colors ])
12 |
13 | if (startX < endX) {
14 | sendBatch()
15 | } else {
16 | postMessage({}) // Done
17 | }
18 | }
19 | sendBatch()
20 | }
21 |
22 | class Scene {
23 | constructor(width, height) {
24 | this.width = width
25 | this.height = height
26 | }
27 |
28 | *getColors(x, width) {
29 | for (const pixel of this.camera.pixelsForWorld(this.world, x, x + width)) {
30 | yield pixel.color
31 | }
32 | }
33 |
34 | getColorData(x, width) {
35 | const { data } = new ImageData(width, this.height)
36 | let index = 0
37 | for (const color of this.getColors(x, width)) {
38 | for (const value of color.rgba) {
39 | data[index++] = value
40 | }
41 | }
42 | return data
43 | }
44 |
45 | get camera() {
46 | return Camera.create({
47 | hsize: this.width,
48 | vsize: this.height,
49 | view: Math.PI / 1.5,
50 | transform: Matrix.viewTransform(
51 | Point(0, 2, -5),
52 | Point(0, 2, 0),
53 | Vector(0, 1, 0)
54 | )
55 | })
56 | }
57 |
58 | get world() {
59 | const world = World.of(...this.walls, ...this.shapes)
60 | world.light = new PointLight(Point(5, 1, -6), Color.of(1.6, 1.6, 1.6))
61 | return world
62 | }
63 |
64 | get walls() {
65 | const material = {
66 | color: Color.of(1, 0.9, 0.9),
67 | ambient: 0.05,
68 | diffuse: 0.6,
69 | specular: 0,
70 | }
71 | return [
72 | Plane.create({
73 | ...material,
74 | diffuse: 1.2,
75 | ambient: 0.1,
76 | }),
77 | Plane.create({
78 | ...material,
79 | transform: Matrix.translation(0, 0, 10)
80 | .multiplyBy( Matrix.rotationX(Math.PI / 2) )
81 | }),
82 | Plane.create({
83 | ...material,
84 | transform: Matrix.translation(-8, 0, 10)
85 | .multiplyBy( Matrix.rotationY(90/180 * -Math.PI) )
86 | .multiplyBy( Matrix.rotationX(Math.PI / 2) )
87 | })
88 | ]
89 | }
90 |
91 |
92 | get shapes() {
93 | return [
94 | Sphere.create({
95 | color: Color.of(0.1, 1, 0.5),
96 | diffuse: 0.7,
97 | specular: 0.3,
98 | transform: Matrix.translation(1.5, 0, -1)
99 | .multiplyBy( Matrix.rotationZ(Math.PI / 2) )
100 | .multiplyBy( Matrix.scaling(0.4, 2.2, 1.8) )
101 | }),
102 | Sphere.create({
103 | color: Color.of(0.1, 1, 0.5),
104 | diffuse: 0.5,
105 | specular: 0.3,
106 | transform: Matrix.translation(1.5, 0.4, -1)
107 | .multiplyBy( Matrix.rotationZ(Math.PI / 2) )
108 | .multiplyBy( Matrix.rotationX(Math.PI / 4) )
109 | .multiplyBy( Matrix.scaling(1.6, 0.2, 1.6) )
110 | }),
111 | Sphere.create({
112 | color: Color.of(0.1, 1, 0.5),
113 | diffuse: 0.5,
114 | specular: 0.3,
115 | transform: Matrix.translation(1.5, 0.4, -1)
116 | .multiplyBy( Matrix.rotationZ(Math.PI / 2) )
117 | .multiplyBy( Matrix.rotationX(-Math.PI / 3) )
118 | .multiplyBy( Matrix.scaling(5, 0.2, 1.6) )
119 | })
120 | ]
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/src/controllers/chapter_10_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "stimulus"
2 | import { nextFrame, createCanvasElement } from "../helpers"
3 | import Worker from "./chapter_10_worker"
4 |
5 | const WIDTH = 240 * window.devicePixelRatio
6 | const HEIGHT = 160 * window.devicePixelRatio
7 | const PIXEL_COUNT = WIDTH * HEIGHT
8 | const WORKER_COUNT = navigator.hardwareConcurrency || 4
9 |
10 | export default class extends Controller {
11 | static targets = [ "preview", "stats" ]
12 |
13 | connect() {
14 | this.workers = Array.from({ length: WORKER_COUNT }, _ => new Worker)
15 | this.render()
16 | }
17 |
18 | disconnect() {
19 | this.workers.forEach(worker => worker.terminate())
20 | }
21 |
22 | // Actions
23 |
24 | async render() {
25 | const element = createCanvasElement(WIDTH, HEIGHT)
26 |
27 | await nextFrame()
28 | this.previewTarget.innerHTML = ""
29 | this.previewTarget.appendChild(element)
30 |
31 | await nextFrame()
32 | const stats = await this.writePixels(element)
33 |
34 | await nextFrame()
35 | const { format } = new Intl.NumberFormat
36 | this.statsTarget.style.width = element.style.width
37 | this.statsTarget.textContent = `
38 | Rendered ${format(PIXEL_COUNT)} pixels
39 | in ${format(stats.time)}ms
40 | using ${WORKER_COUNT} web workers.
41 | `
42 | }
43 |
44 | // Private
45 |
46 | writePixels(canvas) {
47 | return new Promise(resolve => {
48 | const startTime = performance.now()
49 | const context = canvas.getContext("2d")
50 | const batchSize = Math.floor(WIDTH / WORKER_COUNT)
51 | let completedWorkerCount = 0
52 |
53 | this.workers.forEach((worker, index) => {
54 | const startX = index * batchSize
55 | const endX = startX + batchSize
56 | worker.postMessage({ startX, endX, width: WIDTH, height: HEIGHT })
57 |
58 | worker.onmessage = async ({ data }) => {
59 | if (data.colors) {
60 | const colors = new Uint8ClampedArray(data.colors)
61 | const imageData = new ImageData(colors, data.width, HEIGHT)
62 | await nextFrame()
63 | context.putImageData(imageData, data.x, 0)
64 | } else {
65 | completedWorkerCount++
66 | if (completedWorkerCount == WORKER_COUNT) {
67 | const time = performance.now() - startTime
68 | resolve({ time })
69 | }
70 | }
71 | }
72 | })
73 | })
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/controllers/chapter_10_worker.js:
--------------------------------------------------------------------------------
1 | onmessage = ({ data }) => {
2 | const scene = new Scene(data.width, data.height)
3 | let { startX, endX } = data
4 |
5 | function sendBatch() {
6 | const x = startX
7 | const width = Math.min(x + 10, endX) - x
8 | startX += width
9 |
10 | const colors = scene.getColorData(x, width).buffer
11 | postMessage({ x, width, colors }, [ colors ])
12 |
13 | if (startX < endX) {
14 | sendBatch()
15 | } else {
16 | postMessage({}) // Done
17 | }
18 | }
19 | sendBatch()
20 | }
21 |
22 | class Scene {
23 | constructor(width, height) {
24 | this.width = width
25 | this.height = height
26 | }
27 |
28 | *getColors(x, width) {
29 | for (const pixel of this.camera.pixelsForWorld(this.world, x, x + width)) {
30 | yield pixel.color
31 | }
32 | }
33 |
34 | getColorData(x, width) {
35 | const { data } = new ImageData(width, this.height)
36 | let index = 0
37 | for (const color of this.getColors(x, width)) {
38 | for (const value of color.rgba) {
39 | data[index++] = value
40 | }
41 | }
42 | return data
43 | }
44 |
45 | get camera() {
46 | return Camera.create({
47 | hsize: this.width,
48 | vsize: this.height,
49 | view: Math.PI / 3,
50 | transform: Matrix.viewTransform(
51 | Point(-5, 2, -4),
52 | Point(0, 2, 0),
53 | Vector(0, 1, 0)
54 | )
55 | })
56 | }
57 |
58 | get world() {
59 | const world = World.of(this.floor, this.wall, this.sphere)
60 | world.light = new PointLight(Point(-3, 6, -8), Color.WHITE.multiplyBy(1.2))
61 | return world
62 | }
63 |
64 | get floor() {
65 | const pattern = Checkers.of(Color.WHITE, Color.BLACK)
66 | pattern.transform = Matrix.transform({
67 | rotate: { y: 45 },
68 | scale: 2,
69 | })
70 |
71 | return Plane.create({
72 | pattern,
73 | diffuse: 0.7,
74 | ambient: 0.3,
75 | })
76 | }
77 |
78 | get wall() {
79 | const pattern = Ring.of(Color.WHITE, Color.of(1, 0, 0))
80 | return Plane.create({
81 | pattern,
82 | diffuse: 0.7,
83 | ambient: 0.2,
84 | transform: Matrix.transform({
85 | rotate: { x: 90 },
86 | move: { z: 5 },
87 | })
88 | })
89 | }
90 |
91 | get sphere() {
92 | const pattern = Stripe.of(Color.of(1, 0.5, 0), Color.of(1, 0.3, 0))
93 | pattern.transform = Matrix.transform({
94 | rotate: { z: 90 },
95 | scale: 0.05
96 | })
97 |
98 | return Sphere.create({
99 | pattern,
100 | ambient: 0.15,
101 | transform: Matrix.transform({
102 | scale: { x: 0.5, y: 4.5, z: 0.5 },
103 | move: { x: 1.5 },
104 | })
105 | })
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/controllers/chapter_11_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "stimulus"
2 | import { nextFrame, createCanvasElement } from "../helpers"
3 | import Worker from "./chapter_11_worker"
4 |
5 | const WIDTH = 200 * window.devicePixelRatio
6 | const HEIGHT = 150 * window.devicePixelRatio
7 | const PIXEL_COUNT = WIDTH * HEIGHT
8 | const WORKER_COUNT = 10
9 |
10 | export default class extends Controller {
11 | static targets = [ "preview", "stats" ]
12 |
13 | connect() {
14 | this.workers = Array.from({ length: WORKER_COUNT }, _ => new Worker)
15 | this.render()
16 | }
17 |
18 | disconnect() {
19 | this.workers.forEach(worker => worker.terminate())
20 | }
21 |
22 | // Actions
23 |
24 | async render() {
25 | const element = createCanvasElement(WIDTH, HEIGHT)
26 |
27 | await nextFrame()
28 | this.previewTarget.innerHTML = ""
29 | this.previewTarget.appendChild(element)
30 |
31 | await nextFrame()
32 | const stats = await this.writePixels(element)
33 |
34 | await nextFrame()
35 | const { format } = new Intl.NumberFormat
36 | this.statsTarget.style.width = element.style.width
37 | this.statsTarget.textContent = `
38 | Rendered ${format(PIXEL_COUNT)} pixels
39 | in ${format(stats.time)}ms
40 | using ${WORKER_COUNT} web workers.
41 | `
42 | }
43 |
44 | // Private
45 |
46 | writePixels(canvas) {
47 | return new Promise(resolve => {
48 | const startTime = performance.now()
49 | const context = canvas.getContext("2d")
50 | const batchSize = Math.floor(WIDTH / WORKER_COUNT)
51 | let completedWorkerCount = 0
52 |
53 | this.workers.forEach((worker, index) => {
54 | const startX = index * batchSize
55 | const endX = startX + batchSize
56 | worker.postMessage({ startX, endX, width: WIDTH, height: HEIGHT })
57 |
58 | worker.onmessage = async ({ data }) => {
59 | if (data.colors) {
60 | const colors = new Uint8ClampedArray(data.colors)
61 | const imageData = new ImageData(colors, data.width, HEIGHT)
62 | await nextFrame()
63 | context.putImageData(imageData, data.x, 0)
64 | } else {
65 | completedWorkerCount++
66 | if (completedWorkerCount == WORKER_COUNT) {
67 | const time = performance.now() - startTime
68 | resolve({ time })
69 | }
70 | }
71 | }
72 | })
73 | })
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/controllers/chapter_11_worker.js:
--------------------------------------------------------------------------------
1 | onmessage = ({ data }) => {
2 | const scene = new Scene(data.width, data.height)
3 | let { startX, endX } = data
4 |
5 | function sendBatch() {
6 | const x = startX
7 | const width = Math.min(x + 10, endX) - x
8 | startX += width
9 |
10 | const colors = scene.getColorData(x, width).buffer
11 | postMessage({ x, width, colors }, [ colors ])
12 |
13 | if (startX < endX) {
14 | sendBatch()
15 | } else {
16 | postMessage({}) // Done
17 | }
18 | }
19 | sendBatch()
20 | }
21 |
22 | class Scene {
23 | constructor(width, height) {
24 | this.width = width
25 | this.height = height
26 | }
27 |
28 | *getColors(x, width) {
29 | for (const pixel of this.camera.pixelsForWorld(this.world, x, x + width)) {
30 | yield pixel.color
31 | }
32 | }
33 |
34 | getColorData(x, width) {
35 | const { data } = new ImageData(width, this.height)
36 | let index = 0
37 | for (const color of this.getColors(x, width)) {
38 | for (const value of color.rgba) {
39 | data[index++] = value
40 | }
41 | }
42 | return data
43 | }
44 |
45 | get camera() {
46 | return Camera.create({
47 | hsize: this.width,
48 | vsize: this.height,
49 | view: Math.PI / 3,
50 | transform: Matrix.viewTransform(
51 | Point(-2.6, 1.5, -3.9),
52 | Point(-0.6, 1, -0.8),
53 | Vector(0, 1, 0)
54 | )
55 | })
56 | }
57 |
58 | get world() {
59 | const world = World.of(this.floor, ...this.redSpheres, this.blueGlassSphere, this.greenGlassSphere)
60 | world.light = new PointLight(Point(-4.9, 4.9, -1), Color.WHITE)
61 | return world
62 | }
63 |
64 | get floor() {
65 | const pattern = Checkers.of(Color.of(0.35, 0.35, 0.35), Color.of(0.65, 0.65, 0.65))
66 | pattern.transform = Matrix.transform({
67 | rotate: { y: 45 }
68 | })
69 |
70 | return Plane.create({
71 | pattern,
72 | reflective: 0.4,
73 | specular: 0
74 | })
75 | }
76 |
77 | get redSpheres() {
78 | const material = {
79 | color: Color.of(1, 0.3, 0.2),
80 | specular: 0.4,
81 | shininess: 5,
82 | }
83 |
84 | return [
85 | Sphere.create({ ...material,
86 | transform: Matrix.transform({
87 | move: { x: 6, y: 1, z: 4 },
88 | })
89 | }),
90 | Sphere.create({ ...material,
91 | transform: Matrix.transform({
92 | move: { x: 2, y: 1, z: 3 },
93 | })
94 | }),
95 | Sphere.create({ ...material,
96 | transform: Matrix.transform({
97 | move: { x: -1, y: 1, z: 2 },
98 | })
99 | }),
100 | ]
101 | }
102 |
103 | get blueGlassSphere() {
104 | return Sphere.create({
105 | color: Color.of(0, 0, 0.2),
106 | ambient: 0,
107 | diffuse: 0.4,
108 | specular: 0.9,
109 | shininess: 300,
110 | reflective: 0.9,
111 | transparency: 0.9,
112 | refractive: 1.5,
113 | transform: Matrix.transform({
114 | scale: 0.7,
115 | move: { x: 0.6, y: 0.7, z: -0.6 },
116 | })
117 | })
118 | }
119 |
120 | get greenGlassSphere() {
121 | return Sphere.create({
122 | color: Color.of(0, 0.2, 0),
123 | ambient: 0,
124 | diffuse: 0.4,
125 | specular: 0.9,
126 | shininess: 300,
127 | reflective: 0.9,
128 | transparency: 0.9,
129 | refractive: 1.5,
130 | transform: Matrix.transform({
131 | scale: 0.5,
132 | move: { x: -0.7, y: 0.5, z: -0.8 },
133 | })
134 | })
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/src/controllers/chapter_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "stimulus"
2 |
3 | export default class extends Controller {
4 | static targets = [ "content" ]
5 |
6 | async toggle() {
7 | this.content = this.open ? await this.getContent() : ""
8 | }
9 |
10 | // Private
11 |
12 | async getContent() {
13 | const response = await fetch(this.url)
14 | const html = await response.text()
15 | return html
16 | }
17 |
18 | get open() {
19 | return this.element.open
20 | }
21 |
22 | get url() {
23 | return `/chapters/${this.number}.html`
24 | }
25 |
26 | get number() {
27 | return this.data.get("number")
28 | }
29 |
30 | set content(html) {
31 | this.contentTarget.innerHTML = html
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/controllers/tests_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "stimulus"
2 | import { nextIdle, nextFrame } from "../helpers"
3 |
4 | export default class extends Controller {
5 | static targets = [ "summary", "output" ]
6 |
7 | async initialize() {
8 | await nextIdle()
9 | const response = await fetch(this.data.get("url"))
10 | const text = await response.text()
11 |
12 | await nextFrame()
13 | this.outputTarget.innerHTML = formatTestOutput(text)
14 |
15 | const match = text.match(/\d+ tests passed/)
16 | if (match) this.summaryTarget.textContent = match[0]
17 |
18 | this.element.hidden = false
19 | }
20 | }
21 |
22 | function formatTestOutput(text) {
23 | const element = document.createElement("div")
24 | element.textContent = text
25 | return element.innerHTML
26 | .replace(/^\s*/mg, "")
27 | .replace(/yarn run[^\n]*\n/, "")
28 | .replace(/\$ ava [^\n]*\n/, "")
29 | .replace(/\d+ tests passed[\s\S]*/, "")
30 | .replace(/✔/g, `✔`)
31 | .replace(/✖/g, `✖`)
32 | }
33 |
--------------------------------------------------------------------------------
/src/helpers/canvas_helpers.js:
--------------------------------------------------------------------------------
1 | import { Canvas, Color } from "../models"
2 |
3 | const DPR = window.devicePixelRatio
4 |
5 | export class DOMCanvasProxy extends Canvas {
6 | constructor() {
7 | super(...arguments)
8 | this.element = createCanvasElement(this.width, this.height, this.fillColor)
9 | this.context = this.element.getContext("2d")
10 | }
11 |
12 | writePixel(x, y, color) {
13 | const result = super.writePixel(x, y, color)
14 | if (result) {
15 | const imageData = new ImageData(color.rgba, 1, 1)
16 | this.context.putImageData(imageData, x, y)
17 | }
18 | return result
19 | }
20 | }
21 |
22 | export function createCanvasElement(width, height, fillColor = Color.BLACK) {
23 | const element = document.createElement("canvas")
24 | const context = element.getContext("2d")
25 |
26 | element.width = width
27 | element.height = height
28 | element.style.width = `${width / DPR}px`
29 | element.style.height = `${height / DPR}px`
30 |
31 | context.fillStyle = `rgb(${fillColor.rgb})`
32 | context.fillRect(0, 0, width, height)
33 |
34 | return element
35 | }
36 |
--------------------------------------------------------------------------------
/src/helpers/index.js:
--------------------------------------------------------------------------------
1 | export { DOMCanvasProxy, createCanvasElement } from "./canvas_helpers"
2 | export { nextFrame, nextIdle } from "./timing_helpers"
3 |
--------------------------------------------------------------------------------
/src/helpers/timing_helpers.js:
--------------------------------------------------------------------------------
1 | export function nextFrame() {
2 | return new Promise(resolve => requestAnimationFrame(resolve))
3 | }
4 |
5 | export function nextIdle() {
6 | return new Promise(resolve => {
7 | window.requestIdleCallback
8 | ? requestIdleCallback(resolve)
9 | : setTimeout(resolve, 1)
10 | })
11 | }
12 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import { Application } from "stimulus"
2 | import { definitionsFromContext } from "stimulus/webpack-helpers"
3 |
4 | const application = Application.start()
5 | const context = require.context("./controllers", true, /\.js$/)
6 | application.load(definitionsFromContext(context))
7 |
--------------------------------------------------------------------------------
/src/models/camera.js:
--------------------------------------------------------------------------------
1 | import { Matrix } from "./matrix"
2 | import { Point } from "./position"
3 | import { Ray } from "./ray"
4 | import { Canvas } from "./canvas"
5 |
6 | export class Camera {
7 | static create(...args) {
8 | return new Camera(...args)
9 | }
10 |
11 | constructor({ hsize, vsize, view, transform } = {}) {
12 | this.hsize = hsize
13 | this.vsize = vsize
14 | this.view = view
15 | this.transform = transform || Matrix.IDENTITY
16 | }
17 |
18 | rayForPixel(x, y) {
19 | const xoffset = (x + 0.5) * this.pixelSize
20 | const yoffset = (y + 0.5) * this.pixelSize
21 |
22 | const worldX = this.halfWidth - xoffset
23 | const worldY = this.halfHeight - yoffset
24 |
25 | const pixel = this.transform.inverse.multiplyBy(Point(worldX, worldY, - 1))
26 | const origin = this.transform.inverse.multiplyBy(Point(0, 0, 0))
27 | const direction = pixel.subtract(origin).normalize
28 |
29 | return new Ray(origin, direction)
30 | }
31 |
32 | *pixelsForWorld(world, startX = 0, endX = this.hsize) {
33 | for (let y = 0; y < this.vsize; y++) {
34 | for (let x = startX; x < endX; x++) {
35 | const ray = this.rayForPixel(x, y)
36 | const color = world.colorAt(ray)
37 | yield({ x, y, color })
38 | }
39 | }
40 | }
41 |
42 | render(world) {
43 | const canvas = new Canvas(this.hsize, this.vsize)
44 | for (const { x, y, color } of this.pixelsForWorld(world)) {
45 | canvas.writePixel(x, y, color)
46 | }
47 | return canvas
48 | }
49 |
50 | get pixelSize() {
51 | const value = (this.halfWidth * 2) / this.hsize
52 | Object.defineProperty(this, "pixelSize", { value })
53 | return value
54 | }
55 |
56 | get aspect() {
57 | const value = this.hsize / this.vsize
58 | Object.defineProperty(this, "aspect", { value })
59 | return value
60 | }
61 |
62 | get halfWidth() {
63 | const value = this.aspect >= 1
64 | ? this.halfView
65 | : this.halfView * this.aspect
66 | Object.defineProperty(this, "halfWidth", { value })
67 | return value
68 | }
69 |
70 | get halfHeight() {
71 | const value = this.aspect >= 1
72 | ? this.halfView / this.aspect
73 | : this.halfView
74 | Object.defineProperty(this, "halfHeight", { value })
75 | return value
76 | }
77 |
78 | get halfView() {
79 | const value = Math.tan(this.view / 2)
80 | Object.defineProperty(this, "halfView", { value })
81 | return value
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/models/canvas.js:
--------------------------------------------------------------------------------
1 | import { Color } from "./color"
2 | import { CanvasPPM } from "./canvas_ppm"
3 |
4 | export class Canvas {
5 | constructor(width, height, fillColor = Color.BLACK) {
6 | this.width = width
7 | this.height = height
8 | this.fillColor = fillColor
9 | this.pixels = array(height, () => array(width, fillColor))
10 | }
11 |
12 | *[Symbol.iterator]() {
13 | for (const [ y, colors ] of this.pixels.entries()) {
14 | for (const [ x, color ] of colors.entries()) {
15 | yield({ x, y, color })
16 | }
17 | }
18 | }
19 |
20 | hasPixelAt(x, y) {
21 | return x >= 0 && x < this.width && y >= 0 && y < this.height
22 | }
23 |
24 | pixelAt(x, y) {
25 | if (this.hasPixelAt(x, y)) {
26 | return this.pixels[y][x]
27 | }
28 | }
29 |
30 | writePixel(x, y, color) {
31 | if (this.hasPixelAt(x, y)) {
32 | return this.pixels[y][x] = color
33 | }
34 | }
35 |
36 | toPPM() {
37 | return new CanvasPPM(this)
38 | }
39 | }
40 |
41 | const array = (length, fill) =>
42 | typeof fill == "function"
43 | ? Array.from({ length }, fill)
44 | : Array(length).fill(fill)
45 |
--------------------------------------------------------------------------------
/src/models/canvas_ppm.js:
--------------------------------------------------------------------------------
1 | export class CanvasPPM {
2 | constructor(canvas) {
3 | this.canvas = canvas
4 | }
5 |
6 | toString() {
7 | return this.lines.join("\n") + "\n"
8 | }
9 |
10 | toBlob() {
11 | return new Blob([ this.toString() ], { type: "image/x-portable-pixmap" })
12 | }
13 |
14 | get lines() {
15 | return [
16 | ...this.headerLines,
17 | ...this.dataLines
18 | ]
19 | }
20 |
21 | get headerLines() {
22 | return [
23 | this.identifier,
24 | this.dimensions,
25 | this.maxColorValue
26 | ]
27 | }
28 |
29 | get dataLines() {
30 | const lines = []
31 | for (const row of this.canvas.pixels) {
32 | let line = []
33 | for (const color of row) {
34 | for (const number of color.rgb) {
35 | const data = number.toString()
36 | if (data.length + line.length * 4 > this.maxLineLength) {
37 | lines.push(line.join(" "))
38 | line = [data]
39 | } else {
40 | line.push(data)
41 | }
42 | }
43 | }
44 | if (line.length) {
45 | lines.push(line.join(" "))
46 | }
47 | }
48 | return lines
49 | }
50 |
51 | get identifier() {
52 | return "P3"
53 | }
54 |
55 | get dimensions() {
56 | return `${this.canvas.width} ${this.canvas.height}`
57 | }
58 |
59 | get maxColorValue() {
60 | return 255
61 | }
62 |
63 | get maxLineLength() {
64 | return 70
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/models/color.js:
--------------------------------------------------------------------------------
1 | import { Tuple } from "./tuple"
2 |
3 | export class Color extends Tuple {
4 | get red() { return this[0] }
5 | get green() { return this[1] }
6 | get blue() { return this[2] }
7 |
8 | get rgb() {
9 | const values = this.multiplyBy(255).clamp(0, 255).round
10 | const value = Uint8ClampedArray.of(...values)
11 | Object.defineProperty(this, "rgb", { value })
12 | return value
13 | }
14 |
15 | get rgba() {
16 | const value = Uint8ClampedArray.of(...this.rgb, 255)
17 | Object.defineProperty(this, "rgba", { value })
18 | return value
19 | }
20 | }
21 |
22 | Color.BLACK = Color.of(0, 0, 0)
23 | Color.WHITE = Color.of(1, 1, 1)
24 |
--------------------------------------------------------------------------------
/src/models/index.js:
--------------------------------------------------------------------------------
1 | export * from "./camera"
2 | export * from "./canvas"
3 | export * from "./color"
4 | export * from "./intersection"
5 | export * from "./intersections"
6 | export * from "./material"
7 | export * from "./matrix"
8 | export * from "./pattern"
9 | export * from "./patterns"
10 | export * from "./point_light"
11 | export * from "./position"
12 | export * from "./ray"
13 | export * from "./shape"
14 | export * from "./shapes"
15 | export * from "./tuple"
16 | export * from "./world"
17 |
--------------------------------------------------------------------------------
/src/models/intersection.js:
--------------------------------------------------------------------------------
1 | export const EPSILON = 0.000001
2 |
3 | export class Intersection {
4 | constructor(t, object) {
5 | this.t = t
6 | this.object = object
7 | }
8 |
9 | prepare(ray, intersections) {
10 | this.point = ray.position(this.t)
11 | this.eyev = ray.direction.negate
12 | this.normalv = this.object.normalAt(this.point)
13 | this.reflectv = ray.direction.reflect(this.normalv)
14 |
15 | if (this.normalv.dotProduct(this.eyev) < 0) {
16 | this.inside = true
17 | this.normalv = this.normalv.negate
18 | } else {
19 | this.inside = false
20 | }
21 |
22 | const { point } = this
23 | const offset = this.normalv.multiplyBy(EPSILON)
24 | this.point = point.add(offset)
25 | this.underPoint = point.subtract(offset)
26 |
27 | if (intersections) {
28 | const objects = new Set
29 |
30 | for (const intersection of intersections) {
31 | const { object } = intersection
32 |
33 | if (intersection === this) {
34 | this.n1 = objects.size ? last(objects).material.refractive : 1.0
35 | }
36 |
37 | objects.has(object) ? objects.delete(object) : objects.add(object)
38 |
39 | if (intersection === this) {
40 | this.n2 = objects.size ? last(objects).material.refractive : 1.0
41 | break
42 | }
43 | }
44 | }
45 | }
46 |
47 | get reflectance() {
48 | const value = this.schlick
49 | Object.defineProperty(this, "reflectance", { value })
50 | return value
51 | }
52 |
53 | get schlick() {
54 | const { n1, n2 } = this
55 | let cos = this.eyev.dotProduct(this.normalv)
56 | if (n1 > n2) {
57 | const n = n1 / n2
58 | const sin2t = n**2 * (1.0 - cos**2)
59 | if (sin2t > 1.0) {
60 | return 1.0
61 | }
62 | cos = Math.sqrt(1.0 - sin2t)
63 | }
64 | const r0 = ((n1 - n2) / (n1 + n2))**2
65 | return r0 + (1 - r0) * (1 - cos)**5
66 | }
67 | }
68 |
69 | function last(set) {
70 | return Array.from(set).slice(-1)[0]
71 | }
72 |
--------------------------------------------------------------------------------
/src/models/intersections.js:
--------------------------------------------------------------------------------
1 | export class Intersections extends Array {
2 | get hit() {
3 | return this.visible.sorted[0]
4 | }
5 |
6 | get sorted() {
7 | return this.slice(0).sort((a, b) => a.t - b.t)
8 | }
9 |
10 | get visible() {
11 | return this.filter(i => i.t > 0)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/models/material.js:
--------------------------------------------------------------------------------
1 | import { Color } from "./color"
2 |
3 | export class Material {
4 | static create(attributes = {}) {
5 | return new Material(attributes)
6 | }
7 |
8 | constructor({ color, pattern, ambient, diffuse, specular, shininess, reflective, refractive, transparency } = {}) {
9 | this.color = color || Color.WHITE
10 | this.pattern = pattern
11 | this.ambient = typeof ambient == "number" ? ambient : 0.1
12 | this.diffuse = typeof diffuse == "number" ? diffuse : 0.9
13 | this.specular = typeof specular == "number" ? specular : 0.9
14 | this.shininess = typeof shininess == "number" ? shininess : 200
15 | this.reflective = typeof reflective == "number" ? reflective : 0.0
16 | this.refractive = typeof refractive == "number" ? refractive : 1.0
17 | this.transparency = typeof transparency == "number" ? transparency : 0.0
18 | }
19 |
20 | lighting({ object, light, point, eyev, normalv, shadowed }) {
21 | const color = this.pattern ? this.pattern.colorAtShape(object, point) : this.color
22 | const effectiveColor = color.multiplyBy(light.intensity)
23 | const lightv = light.position.subtract(point).normalize
24 | const ambient = effectiveColor.multiplyBy(this.ambient)
25 | if (shadowed) {
26 | return ambient
27 | }
28 |
29 | const lightDotNormal = lightv.dotProduct(normalv)
30 | let diffuse, specular
31 |
32 | if (lightDotNormal < 0) {
33 | diffuse = Color.BLACK
34 | specular = Color.BLACK
35 | } else {
36 | diffuse = effectiveColor.multiplyBy(this.diffuse).multiplyBy(lightDotNormal)
37 |
38 | const reflectv = lightv.negate.reflect(normalv)
39 | const reflectvDotEyev = reflectv.dotProduct(eyev)
40 |
41 | if (reflectvDotEyev <= 0) {
42 | specular = Color.BLACK
43 | } else {
44 | specular = light.intensity.multiplyBy(this.specular).multiplyBy(reflectvDotEyev ** this.shininess)
45 | }
46 | }
47 |
48 | return ambient.add(diffuse).add(specular)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/models/math.js:
--------------------------------------------------------------------------------
1 | export function dotProduct(a, b) {
2 | return a.reduce((sum, value, index) => {
3 | return sum + value * b[index]
4 | }, 0)
5 | }
6 |
7 | export function product(a, b) {
8 | return a == 0 ? 0 : a * b
9 | }
10 |
--------------------------------------------------------------------------------
/src/models/matrix.js:
--------------------------------------------------------------------------------
1 | import { Tuple } from "./tuple"
2 | import { dotProduct } from "./math"
3 |
4 | const CACHE = {}
5 |
6 | export class Matrix extends Array {
7 | static of(...rows) {
8 | const key = JSON.stringify(rows)
9 | return CACHE[key] || (CACHE[key] = super.of(...rows))
10 | }
11 |
12 | static translation(x, y, z) {
13 | const matrix = this.identity
14 | matrix[0][3] = x
15 | matrix[1][3] = y
16 | matrix[2][3] = z
17 | return matrix
18 | }
19 |
20 | static scaling(x, y, z) {
21 | const matrix = this.identity
22 | matrix[0][0] = x
23 | matrix[1][1] = y
24 | matrix[2][2] = z
25 | return matrix
26 | }
27 |
28 | static shearing(xy, xz, yx, yz, zx, zy) {
29 | const matrix = this.identity
30 | matrix[0][1] = xy
31 | matrix[0][2] = xz
32 | matrix[1][0] = yx
33 | matrix[1][2] = yz
34 | matrix[2][0] = zx
35 | matrix[2][1] = zy
36 | return matrix
37 | }
38 |
39 | static rotationX(radians) {
40 | const matrix = this.identity
41 | matrix[1][1] = Math.cos(radians)
42 | matrix[1][2] = -Math.sin(radians)
43 | matrix[2][1] = Math.sin(radians)
44 | matrix[2][2] = Math.cos(radians)
45 | return matrix
46 | }
47 |
48 | static rotationY(radians) {
49 | const matrix = this.identity
50 | matrix[0][0] = Math.cos(radians)
51 | matrix[0][2] = Math.sin(radians)
52 | matrix[2][0] = -Math.sin(radians)
53 | matrix[2][2] = Math.cos(radians)
54 | return matrix
55 | }
56 |
57 | static rotationZ(radians) {
58 | const matrix = this.identity
59 | matrix[0][0] = Math.cos(radians)
60 | matrix[0][1] = -Math.sin(radians)
61 | matrix[1][0] = Math.sin(radians)
62 | matrix[1][1] = Math.cos(radians)
63 | return matrix
64 | }
65 |
66 | static viewTransform(from, to, up) {
67 | const forward = to.subtract(from).normalize
68 | const left = forward.crossProduct(up.normalize)
69 | const trueUp = left.crossProduct(forward)
70 | const backward = forward.negate
71 | return Matrix.of(
72 | [ ...left ],
73 | [ ...trueUp ],
74 | [ ...backward ],
75 | [ 0, 0, 0, 1 ],
76 | ).multiplyBy(
77 | Matrix.translation(...from.negate)
78 | )
79 | }
80 |
81 | static get identity() {
82 | return Matrix.from([
83 | [ 1, 0, 0, 0 ],
84 | [ 0, 1, 0, 0 ],
85 | [ 0, 0, 1, 0 ],
86 | [ 0, 0, 0, 1 ],
87 | ])
88 | }
89 |
90 | static transform(transforms) {
91 | let matrix = this.identity
92 |
93 | Object.keys(transforms).reverse().forEach(key => {
94 | let value = transforms[key]
95 | if (typeof value == "number") {
96 | value = { x: value, y: value, z: value }
97 | }
98 | const { x, y, z } = { x: 0, y: 0, z: 0, ...value }
99 |
100 | switch (key) {
101 | case "move":
102 | matrix = matrix.multiplyBy( Matrix.translation(x, y, z) )
103 | break
104 | case "rotate":
105 | if (x) matrix = matrix.multiplyBy( Matrix.rotationX(radians(x)) )
106 | if (y) matrix = matrix.multiplyBy( Matrix.rotationY(radians(y)) )
107 | if (z) matrix = matrix.multiplyBy( Matrix.rotationZ(radians(z)) )
108 | break
109 | case "scale":
110 | matrix = matrix.multiplyBy( Matrix.scaling(x, y, z) )
111 | break
112 | }
113 | })
114 |
115 | return matrix
116 | }
117 |
118 | multiplyBy(object) {
119 | if (object === Matrix.IDENTITY || this === Matrix.IDENTITY) {
120 | return object
121 | }
122 | if (object instanceof Tuple) {
123 | const values = this.map(row => dotProduct(row, object))
124 | return object.constructor.of(...values)
125 | } else {
126 | const matrix = object
127 | return this.map(row => row.map((_, index) => dotProduct(row, matrix.columns[index])))
128 | }
129 | }
130 |
131 | divideBy(number) {
132 | return this.map(values => values.map(value => value / number))
133 | }
134 |
135 | submatrix(row, column) {
136 | return this.reduce((matrix, values, index) => {
137 | if (index !== row) matrix.push(values.filter((_, index) => index !== column))
138 | return matrix
139 | }, new Matrix)
140 | }
141 |
142 | minor(row, column) {
143 | return this.submatrix(row, column).determinant
144 | }
145 |
146 | cofactor(row, column) {
147 | const minor = this.minor(row, column)
148 | return isOdd(row + column) ? minor * -1 : minor
149 | }
150 |
151 | get columns() {
152 | const value = this.map((_, index) => Array.from({ length: this.length }, (_, rowIndex) => this[rowIndex][index]))
153 | Object.defineProperty(this, "columns", { value })
154 | return value
155 | }
156 |
157 | get transpose() {
158 | const value = Matrix.of(...this.columns)
159 | Object.defineProperty(this, "transpose", { value })
160 | return value
161 | }
162 |
163 | get determinant() {
164 | const value = this.length == 2
165 | ? this[0][0] * this[1][1] - this[0][1] * this[1][0]
166 | : dotProduct(this[0], this[0].map((_, index) => this.cofactor(0, index)))
167 | Object.defineProperty(this, "determinant", { value })
168 | return value
169 | }
170 |
171 | get cofactors() {
172 | const values = this.map((values, row) => values.map((_, column) => this.cofactor(row, column)))
173 | const value = Matrix.of(...values)
174 | Object.defineProperty(this, "cofactors", { value })
175 | return value
176 | }
177 |
178 | get inverse() {
179 | const value = this.cofactors.transpose.divideBy(this.determinant)
180 | Object.defineProperty(this, "inverse", { value })
181 | return value
182 | }
183 |
184 | get isInvertible() {
185 | return this.determinant != 0
186 | }
187 |
188 | get fixed() {
189 | return this.map(values => values.map(value => Number(value.toFixed(5))))
190 | }
191 | }
192 |
193 | Matrix.IDENTITY = Matrix.identity
194 |
195 | function isOdd(number) {
196 | return number & 1 === 1
197 | }
198 |
199 | function radians(degrees) {
200 | return degrees / 180 * Math.PI
201 | }
202 |
--------------------------------------------------------------------------------
/src/models/pattern.js:
--------------------------------------------------------------------------------
1 | import { Matrix } from "./matrix"
2 | import { Color } from "./color"
3 |
4 | export class Pattern extends Array {
5 | get transform() {
6 | return Matrix.IDENTITY
7 | }
8 |
9 | set transform(value) {
10 | Object.defineProperty(this, "transform", { value })
11 | }
12 |
13 | colorAtShape(shape, point) {
14 | if (shape) {
15 | point = shape.transform.inverse.multiplyBy(point)
16 | }
17 | if (this.transform !== Matrix.IDENTITY) {
18 | point = this.transform.inverse.multiplyBy(point)
19 | }
20 | return this.colorAt(point)
21 | }
22 |
23 | colorAt({ x, y, z }) {
24 | return Color.of(x, y, z)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/models/patterns/checkers.js:
--------------------------------------------------------------------------------
1 | import { Pattern } from "../pattern"
2 |
3 | export class Checkers extends Pattern {
4 | colorAt({ x, y, z }) {
5 | return this[
6 | Math.abs(
7 | Math.floor(x) +
8 | Math.floor(y) +
9 | Math.floor(z)
10 | ) % this.length
11 | ]
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/models/patterns/gradient.js:
--------------------------------------------------------------------------------
1 | import { Pattern } from "../pattern"
2 |
3 | export class Gradient extends Pattern {
4 | colorAt({ x }) {
5 | const distance = this[1].subtract(this[0])
6 | const fraction = x - Math.floor(x)
7 | return this[0].add(distance.multiplyBy(fraction))
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/models/patterns/index.js:
--------------------------------------------------------------------------------
1 | export * from "./checkers"
2 | export * from "./gradient"
3 | export * from "./ring"
4 | export * from "./stripe"
5 |
--------------------------------------------------------------------------------
/src/models/patterns/ring.js:
--------------------------------------------------------------------------------
1 | import { Pattern } from "../pattern"
2 |
3 | export class Ring extends Pattern {
4 | colorAt({ x, z }) {
5 | return this[
6 | Math.floor(
7 | Math.sqrt(x ** 2 + z ** 2)
8 | ) % this.length
9 | ]
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/models/patterns/stripe.js:
--------------------------------------------------------------------------------
1 | import { Pattern } from "../pattern"
2 |
3 | export class Stripe extends Pattern {
4 | colorAt({ x }) {
5 | return this[ Math.abs(Math.floor(x) % this.length) ]
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/models/point_light.js:
--------------------------------------------------------------------------------
1 | export class PointLight {
2 | constructor(position, intensity) {
3 | this.position = position
4 | this.intensity = intensity
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/src/models/position.js:
--------------------------------------------------------------------------------
1 | import { Tuple } from "./tuple"
2 |
3 | export class Position extends Tuple {
4 | get x() { return this[0] }
5 | get y() { return this[1] }
6 | get z() { return this[2] }
7 | get w() { return this[3] }
8 |
9 | get isPoint() { return this.w == 1 }
10 | get isVector() { return this.w == 0 }
11 |
12 | crossProduct(position) {
13 | return this.constructor.of(
14 | this.y * position.z - this.z * position.y,
15 | this.z * position.x - this.x * position.z,
16 | this.x * position.y - this.y * position.x,
17 | this.w
18 | )
19 | }
20 |
21 | reflect(normal) {
22 | return this.subtract(normal.multiplyBy(this.dotProduct(normal) * 2))
23 | }
24 | }
25 |
26 | export function Point(x, y, z) {
27 | return Position.of(x, y, z, 1)
28 | }
29 |
30 | export function Vector(x, y, z) {
31 | return Position.of(x, y, z, 0)
32 | }
33 |
--------------------------------------------------------------------------------
/src/models/ray.js:
--------------------------------------------------------------------------------
1 | export class Ray {
2 | constructor(origin, direction) {
3 | this.origin = origin
4 | this.direction = direction
5 | }
6 |
7 | position(t) {
8 | return this.origin.add(this.direction.multiplyBy(t))
9 | }
10 |
11 | intersect(shape) {
12 | const ray = this.transform(shape.transform.inverse)
13 | return shape.intersect(ray)
14 | }
15 |
16 | transform(matrix) {
17 | const origin = matrix.multiplyBy(this.origin)
18 | const direction = matrix.multiplyBy(this.direction)
19 | return new Ray(origin, direction)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/models/shape.js:
--------------------------------------------------------------------------------
1 | import { Matrix } from "./matrix"
2 | import { Point, Vector } from "./position"
3 | import { Material } from "./material"
4 |
5 | export class Shape {
6 | static create(attributes = {}) {
7 | return new this(attributes)
8 | }
9 |
10 | static glass(attributes = {}) {
11 | return this.create({ transparency: 1.0, refractive: 1.5, ...attributes })
12 | }
13 |
14 | constructor(attributes) {
15 | this.material = Material.create(attributes)
16 | this.transform = attributes.transform || Matrix.IDENTITY
17 | }
18 |
19 | normalAt(point) {
20 | const localPoint = this.transform.inverse.multiplyBy(point)
21 | const localNormal = this.localNormalAt(localPoint)
22 | const worldNormal = this.transform.inverse.transpose.multiplyBy(localNormal)
23 | return Vector(...worldNormal).normalize
24 | }
25 |
26 | localNormalAt(point) {
27 | return Vector(...point)
28 | }
29 |
30 | intersect(ray) {
31 | throw new Error("Must be implemented in subclass")
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/models/shapes/index.js:
--------------------------------------------------------------------------------
1 | export * from "./plane"
2 | export * from "./sphere"
3 |
--------------------------------------------------------------------------------
/src/models/shapes/plane.js:
--------------------------------------------------------------------------------
1 | import { Shape } from "../shape"
2 | import { Vector } from "../position"
3 | import { Intersection, EPSILON } from "../intersection"
4 | import { Intersections } from "../intersections"
5 |
6 | export class Plane extends Shape {
7 | localNormalAt() {
8 | return Vector(0, 1, 0)
9 | }
10 |
11 | intersect(ray) {
12 | const intersections = new Intersections
13 | if (Math.abs(ray.direction.y) >= EPSILON) {
14 | const t = ray.origin.negate.y / ray.direction.y
15 | intersections.push(new Intersection(t, this))
16 | }
17 | return intersections
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/models/shapes/sphere.js:
--------------------------------------------------------------------------------
1 | import { Shape } from "../shape"
2 | import { Point } from "../position"
3 | import { Intersection } from "../intersection"
4 | import { Intersections } from "../intersections"
5 |
6 | export class Sphere extends Shape {
7 | localNormalAt(point) {
8 | return point.subtract(Point(0, 0, 0))
9 | }
10 |
11 | intersect(ray) {
12 | const intersections = new Intersections
13 |
14 | const vectorToRay = ray.origin.subtract(Point(0, 0, 0))
15 | const a = ray.direction.dotProduct(ray.direction)
16 | const b = 2 * ray.direction.dotProduct(vectorToRay)
17 | const c = vectorToRay.dotProduct(vectorToRay) - 1
18 | const discriminant = b * b - 4 * a * c
19 |
20 | if (discriminant >= 0) {
21 | const t1 = (-b - Math.sqrt(discriminant)) / (2 * a)
22 | const t2 = (-b + Math.sqrt(discriminant)) / (2 * a)
23 | const i1 = new Intersection(t1, this)
24 | const i2 = new Intersection(t2, this)
25 | t1 > t2
26 | ? intersections.push(i2, i1)
27 | : intersections.push(i1, i2)
28 | }
29 |
30 | return intersections
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/models/tuple.js:
--------------------------------------------------------------------------------
1 | import { dotProduct, product } from "./math"
2 |
3 | export class Tuple extends Array {
4 | add(tuple) {
5 | return this.map((value, index) => value + tuple[index])
6 | }
7 |
8 | subtract(tuple) {
9 | return this.map((value, index) => value - tuple[index])
10 | }
11 |
12 | multiplyBy(object) {
13 | return object instanceof Tuple
14 | ? this.map((value, index) => product(value, object[index]))
15 | : this.map(value => product(value, object))
16 | }
17 |
18 | divideBy(number) {
19 | return this.multiplyBy(1 / number)
20 | }
21 |
22 | dotProduct(tuple) {
23 | return dotProduct(this, tuple)
24 | }
25 |
26 | clamp(min, max) {
27 | return this.map(value => Math.max(min, Math.min(max, value)))
28 | }
29 |
30 | get round() {
31 | const value = this.map(value => Math.round(value))
32 | Object.defineProperty(this, "round", { value })
33 | return value
34 | }
35 |
36 | get fixed() {
37 | return this.map(value => Number(value.toFixed(5)))
38 | }
39 |
40 | get negate() {
41 | const value = this.multiplyBy(-1)
42 | Object.defineProperty(this, "negate", { value })
43 | return value
44 | }
45 |
46 | get normalize() {
47 | const value = this.divideBy(this.magnitude)
48 | Object.defineProperty(this, "normalize", { value })
49 | return value
50 | }
51 |
52 | get magnitude() {
53 | const value = Math.sqrt(this.reduce((sum, value) => {
54 | return sum + value ** 2
55 | }, 0))
56 | Object.defineProperty(this, "magnitude", { value })
57 | return value
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/models/world.js:
--------------------------------------------------------------------------------
1 | import { PointLight } from "./point_light"
2 | import { Point } from "./position"
3 | import { Color } from "./color"
4 | import { Matrix } from "./matrix"
5 | import { Sphere } from "./shapes"
6 | import { Intersections } from "./intersections"
7 | import { Ray } from "./ray"
8 |
9 | const MAX_RECURSIVE_DEPTH = 3
10 |
11 | export class World extends Array {
12 | static get default() {
13 | const world = World.of(
14 | Sphere.create({
15 | color: Color.of(0.8, 1.0, 0.6),
16 | diffuse: 0.7,
17 | specular: 0.2
18 | }),
19 | Sphere.create({
20 | transform: Matrix.scaling(0.5, 0.5, 0.5)
21 | })
22 | )
23 | world.light = new PointLight(Point(-10, 10, -10), Color.WHITE)
24 | return world
25 | }
26 |
27 | intersect(ray) {
28 | return this.reduce((result, object) =>
29 | result.concat(ray.intersect(object))
30 | , new Intersections).sorted
31 | }
32 |
33 | shade(hit, remaining) {
34 | const { light } = this
35 | const { object, point, eyev, normalv } = hit
36 | const { material } = object
37 |
38 | const shadowed = this.isShadowed(point)
39 | const surface = material.lighting({ object, light, point, eyev, normalv, shadowed })
40 |
41 | let reflected = this.reflect(hit, remaining)
42 | let refracted = this.refract(hit, remaining)
43 |
44 | if (material.reflective > 0 && material.transparency > 0) {
45 | reflected = reflected.multiplyBy(hit.reflectance)
46 | refracted = refracted.multiplyBy(1 - hit.reflectance)
47 | }
48 |
49 | return surface.add(reflected).add(refracted)
50 | }
51 |
52 | reflect(hit, remaining = MAX_RECURSIVE_DEPTH) {
53 | if (remaining <= 0 || hit.object.material.reflective == 0) {
54 | return Color.BLACK
55 | }
56 | const reflectRay = new Ray(hit.point, hit.reflectv)
57 | const color = this.colorAt(reflectRay, remaining - 1)
58 | return color.multiplyBy(hit.object.material.reflective)
59 | }
60 |
61 | refract(hit, remaining = MAX_RECURSIVE_DEPTH) {
62 | if (remaining <= 0 || hit.object.material.transparency == 0) {
63 | return Color.BLACK
64 | }
65 |
66 | const nRatio = hit.n1 / hit.n2
67 | const cosI = hit.eyev.dotProduct(hit.normalv)
68 | const sin2t = nRatio**2 * (1 - cosI**2)
69 | if (sin2t > 1) {
70 | return Color.BLACK
71 | }
72 |
73 | const cosT = Math.sqrt(1.0 - sin2t)
74 | const direction = hit.normalv.multiplyBy(nRatio * cosI - cosT).subtract(hit.eyev.multiplyBy(nRatio))
75 | const refractRay = new Ray(hit.underPoint, direction)
76 | const color = this.colorAt(refractRay, remaining - 1)
77 | return color.multiplyBy(hit.object.material.transparency)
78 | }
79 |
80 | colorAt(ray, remaining) {
81 | const intersections = this.intersect(ray)
82 | const { hit } = intersections
83 | if (hit) {
84 | hit.prepare(ray, intersections)
85 | return this.shade(hit, remaining)
86 | } else {
87 | return Color.BLACK
88 | }
89 | }
90 |
91 | isShadowed(point) {
92 | const vector = this.light.position.subtract(point)
93 | const distance = vector.magnitude
94 | const direction = vector.normalize
95 |
96 | const ray = new Ray(point, direction)
97 | const { hit } = this.intersect(ray)
98 |
99 | return hit ? hit.t < distance : false
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/test/camera.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { Camera, Matrix, Point, Vector, World, Color } from "../src/models"
3 |
4 | test("constructing a camera", t => {
5 | const hsize = 160
6 | const vsize = 120
7 | const view = Math.PI / 2
8 | const c = Camera.create({ hsize, vsize, view })
9 | t.is(c.hsize, 160)
10 | t.is(c.vsize, 120)
11 | t.is(c.view, Math.PI / 2)
12 | t.deepEqual(c.transform, Matrix.identity)
13 | })
14 |
15 | test("the pixel size for a horizontal canvas", t => {
16 | const c = Camera.create({ hsize: 200, vsize: 125, view: Math.PI / 2 })
17 | t.is(fixed(c.pixelSize), 0.01)
18 | })
19 |
20 | test("the pixel size for a verical canvas", t => {
21 | const c = Camera.create({ hsize: 125, vsize: 200, view: Math.PI / 2 })
22 | t.is(fixed(c.pixelSize), 0.01)
23 | })
24 |
25 | test("construct a ray through the center of the canvas", t => {
26 | const c = Camera.create({ hsize: 201, vsize: 101, view: Math.PI / 2 })
27 | const r = c.rayForPixel(100, 50)
28 | t.deepEqual(r.origin, Point(0, 0, 0))
29 | t.deepEqual(r.direction.fixed, Vector(0, 0, -1).fixed)
30 | })
31 |
32 | test("construct a ray through a corner of the canvas", t => {
33 | const c = Camera.create({ hsize: 201, vsize: 101, view: Math.PI / 2 })
34 | const r = c.rayForPixel(0, 0)
35 | t.deepEqual(r.origin, Point(0, 0, 0))
36 | t.deepEqual(r.direction.fixed, Vector(0.66519, 0.33259, -0.66851).fixed)
37 | })
38 |
39 | test("construct a ray when the camera is transformed", t => {
40 | const transform = Matrix.rotationY(Math.PI / 4).multiplyBy(Matrix.translation(0, -2, 5))
41 | const c = Camera.create({ hsize: 201, vsize: 101, view: Math.PI / 2, transform })
42 | const r = c.rayForPixel(100, 50)
43 | t.deepEqual(r.origin, Point(0, 2, -5))
44 | t.deepEqual(r.direction.fixed, Vector(Math.SQRT2 / 2, 0, -Math.SQRT2 / 2).fixed)
45 | })
46 |
47 | test("rendering a world with a camera", t => {
48 | const w = World.default
49 | const from = Point(0, 0, -5)
50 | const to = Point(0, 0, 0)
51 | const up = Vector(0, 1, 0)
52 | const transform = Matrix.viewTransform(from, to, up)
53 | const c = Camera.create({ hsize: 11, vsize: 11, view: Math.PI / 2, transform })
54 | const canvas = c.render(w)
55 | t.deepEqual(canvas.pixelAt(5, 5).fixed, Color.of(0.38066, 0.47583, 0.2855).fixed)
56 | })
57 |
58 | function fixed(number, digits = 2) {
59 | return Number(number.toFixed(digits))
60 | }
61 |
--------------------------------------------------------------------------------
/test/canvas.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { Canvas, Color } from "../src/models"
3 |
4 | const black = Color.of(0, 0, 0)
5 | const red = Color.of(1, 0, 0)
6 |
7 | test("creating a canvas", t => {
8 | const c = new Canvas(10, 20)
9 | t.is(c.width, 10)
10 | t.is(c.height, 20)
11 | // every pixel of c is Color.of(0, 0, 0)
12 | for (let x = 0; x < 10; x++) {
13 | for (let y = 0; y < 20; y++) {
14 | t.deepEqual(c.pixelAt(x, y), black)
15 | }
16 | }
17 | })
18 |
19 | test("writing pixels to a canvas", t => {
20 | const c = new Canvas(10, 20)
21 | c.writePixel(2, 3, red)
22 | t.deepEqual(c.pixelAt(2, 3), red)
23 | t.deepEqual(c.pixelAt(2, 2), black)
24 | t.deepEqual(c.pixelAt(1, 3), black)
25 | })
26 |
27 | test("constructing the PPM header", t => {
28 | const c = new Canvas(5, 3)
29 | const lines = `${c.toPPM()}`.split("\n")
30 | t.is(lines[0], "P3")
31 | t.is(lines[1], "5 3")
32 | t.is(lines[2], "255")
33 | })
34 |
35 | test("constructing the PPM pixel data", t => {
36 | const c = new Canvas(5, 3)
37 | c.writePixel(0, 0, Color.of(1.5, 0, 0))
38 | c.writePixel(2, 1, Color.of(0, 0.5, 0))
39 | c.writePixel(4, 2, Color.of(-0.5, 0, 1))
40 | const lines = `${c.toPPM()}`.split("\n")
41 | t.is(lines[3], "255 0 0 0 0 0 0 0 0 0 0 0 0 0 0")
42 | t.is(lines[4], "0 0 0 0 0 0 0 128 0 0 0 0 0 0 0")
43 | t.is(lines[5], "0 0 0 0 0 0 0 0 0 0 0 0 0 0 255")
44 | })
45 |
46 | test("splitting long lines in PPM files", t => {
47 | const c = new Canvas(10, 20, Color.of(1, 0.8, 0.6))
48 | const lines = `${c.toPPM()}`.split("\n")
49 | t.is(lines[3], "255 204 153 255 204 153 255 204 153 255 204 153 255 204 153 255 204")
50 | t.is(lines[4], "153 255 204 153 255 204 153 255 204 153 255 204 153")
51 | t.is(lines[5], "255 204 153 255 204 153 255 204 153 255 204 153 255 204 153 255 204")
52 | t.is(lines[6], "153 255 204 153 255 204 153 255 204 153 255 204 153")
53 | })
54 |
55 | test("PPM files are terminated by a newline", t => {
56 | const c = new Canvas(5, 3)
57 | t.is(`${c.toPPM()}`.slice(-1), "\n")
58 | })
59 |
--------------------------------------------------------------------------------
/test/intersections.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { Intersection, Intersections, Sphere, Ray, Point, Vector, Matrix } from "../src/models"
3 |
4 | test("an intersection encapsulates `t` and `object`", t => {
5 | const s = Sphere.create()
6 | const i = new Intersection(3.5, s)
7 | t.is(i.t, 3.5)
8 | t.is(i.object, s)
9 | })
10 |
11 | test("aggregating intersections", t => {
12 | const s = Sphere.create()
13 | const i1 = new Intersection(1, s)
14 | const i2 = new Intersection(2, s)
15 | const xs = Intersections.of(i1, i2)
16 | t.is(xs.length, 2)
17 | t.is(xs[0], i1)
18 | t.is(xs[1], i2)
19 | })
20 |
21 | test("the hit, when all intersections have positive t", t => {
22 | const s = Sphere.create()
23 | const i1 = new Intersection(1, s)
24 | const i2 = new Intersection(2, s)
25 | const xs = Intersections.of(i1, i2)
26 | t.is(xs.hit, i1)
27 | })
28 |
29 | test("the hit, when some intersections have negative t", t => {
30 | const s = Sphere.create()
31 | const i1 = new Intersection(-1, s)
32 | const i2 = new Intersection(1, s)
33 | const xs = Intersections.of(i1, i2)
34 | t.is(xs.hit, i2)
35 | })
36 |
37 | test("the hit, when all intersections have negative t", t => {
38 | const s = Sphere.create()
39 | const i1 = new Intersection(-2, s)
40 | const i2 = new Intersection(-1, s)
41 | const xs = Intersections.of(i1, i2)
42 | t.is(xs.hit, undefined)
43 | })
44 |
45 | test("the hit is always the lowest non-negative intersection", t => {
46 | const s = Sphere.create()
47 | const i1 = new Intersection(6, s)
48 | const i2 = new Intersection(7, s)
49 | const i3 = new Intersection(-3, s)
50 | const i4 = new Intersection(2, s)
51 | const xs = Intersections.of(i1, i2, i3, i4)
52 | t.is(xs.hit, i4)
53 | })
54 |
55 | test("precomputing the state of an intersection", t => {
56 | const ray = new Ray(Point(0, 0, -5), Vector(0, 0, 1))
57 | const shape = Sphere.create()
58 | const hit = new Intersection(4, shape)
59 | hit.prepare(ray)
60 | t.deepEqual(hit.point.fixed, Point(0, 0, -1))
61 | t.deepEqual(hit.eyev, ray.direction.negate)
62 | t.deepEqual(hit.normalv, hit.object.normalAt(hit.point))
63 | })
64 |
65 | test("precomputing the reflection vector", t => {
66 | const ray = new Ray(Point(0, 0, -1), Vector(0, -Math.SQRT2 / 2, Math.SQRT2 / 2))
67 | const shape = Sphere.create()
68 | const hit = new Intersection(Math.SQRT2, shape)
69 | hit.prepare(ray)
70 | t.deepEqual(hit.reflectv.fixed, Vector(0, Math.SQRT2 / 2, Math.SQRT2 / 2).fixed)
71 | })
72 |
73 | test("an intersection occurs on the outside", t => {
74 | const ray = new Ray(Point(0, 0, -5), Vector(0, 0, 1))
75 | const shape = Sphere.create()
76 | const hit = new Intersection(4, shape)
77 | hit.prepare(ray)
78 | t.is(hit.inside, false)
79 | })
80 |
81 | test("an intersection occurs on the inside", t => {
82 | const ray = new Ray(Point(0, 0, 0), Vector(0, 0, 1))
83 | const shape = Sphere.create()
84 | const hit = new Intersection(1, shape)
85 | hit.prepare(ray)
86 | t.deepEqual(hit.point.fixed, Point(0, 0, 1))
87 | t.deepEqual(hit.eyev, Vector(0, 0, -1))
88 | t.deepEqual(hit.normalv, Vector(0, 0, -1))
89 | t.is(hit.inside, true)
90 | })
91 |
92 | test("the point is offset", t => {
93 | const ray = new Ray(Point(0, 0, -5), Vector(0, 0, 1))
94 | const shape = Sphere.create()
95 | const hit = new Intersection(4, shape)
96 | hit.prepare(ray)
97 | const { z } = hit.point
98 | t.true(z > -1.1 && z < -1, `hit.point.z (${z}) is not between -1.1 and -1 (exclusive)`)
99 | })
100 |
101 | test("the under point is offset below the surface", t => {
102 | const ray = new Ray(Point(0, 0, -5), Vector(0, 0, 1))
103 | const shape = Sphere.glass()
104 | const hit = new Intersection(4, shape)
105 | const xs = Intersections.of(hit)
106 | hit.prepare(ray, xs)
107 | const { z } = hit.underPoint
108 | t.true(z > -1 && z < -0.9, `hit.underPoint.z (${z}) is not between -1 and -0.9 (exclusive)`)
109 | })
110 |
111 | test("n1 and n2 at various intersectionst", t => {
112 | const a = Sphere.glass({ transform: Matrix.scaling(2, 2, 2), refractive: 1.5 })
113 | const b = Sphere.glass({ transform: Matrix.translation(0, 0, -0.25), refractive: 2.0 })
114 | const c = Sphere.glass({ transform: Matrix.translation(0, 0, 0.25), refractive: 2.5 })
115 |
116 | const xs = Intersections.of(
117 | new Intersection(2, a),
118 | new Intersection(2.75, b),
119 | new Intersection(3.25, c),
120 | new Intersection(4.75, b),
121 | new Intersection(5.25, c),
122 | new Intersection(6, a),
123 | )
124 |
125 | const ray = new Ray(Point(0, 0, -4), Vector(0, 0, 1))
126 | xs.forEach(x => x.prepare(ray, xs))
127 |
128 | t.is(xs[0].n1, 1.0)
129 | t.is(xs[0].n2, 1.5)
130 | t.is(xs[1].n1, 1.5)
131 | t.is(xs[1].n2, 2.0)
132 | t.is(xs[2].n1, 2.0)
133 | t.is(xs[2].n2, 2.5)
134 | t.is(xs[3].n1, 2.5)
135 | t.is(xs[3].n2, 2.5)
136 | t.is(xs[4].n1, 2.5)
137 | t.is(xs[4].n2, 1.5)
138 | t.is(xs[5].n1, 1.5)
139 | t.is(xs[5].n2, 1.0)
140 | })
141 |
142 | test("schlick approximation under total internal reflection", t => {
143 | const shape = Sphere.glass()
144 | const ray = new Ray(Point(0, 0, -Math.SQRT2 / 2), Vector(0, 1, 0))
145 | const xs = Intersections.of(
146 | new Intersection(-Math.SQRT2 / 2, shape),
147 | new Intersection( Math.SQRT2 / 2, shape),
148 | )
149 | xs[1].prepare(ray, xs)
150 | t.is(xs[1].schlick, 1.0)
151 | })
152 |
153 | test("schlick approximation with a perpendicular viewing angle", t => {
154 | const shape = Sphere.glass()
155 | const ray = new Ray(Point(0, 0, 0), Vector(0, 1, 0))
156 | const xs = Intersections.of(
157 | new Intersection(-1, shape),
158 | new Intersection( 1, shape),
159 | )
160 | xs[1].prepare(ray, xs)
161 | t.is(Number(xs[1].schlick.toFixed(2)), 0.04)
162 | })
163 |
164 | test("schlick approximation with small angle and n2 > n1", t => {
165 | const shape = Sphere.glass()
166 | const ray = new Ray(Point(0, 0.99, -2), Vector(0, 0, 1))
167 | const xs = Intersections.of(
168 | new Intersection(1.8589, shape),
169 | )
170 | xs[0].prepare(ray, xs)
171 | t.is(Number(xs[0].schlick.toFixed(5)), 0.48873)
172 | })
173 |
--------------------------------------------------------------------------------
/test/lights.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { PointLight, Point, Color } from "../src/models"
3 |
4 | test("a point light has a position and intensity", t => {
5 | const position = Point(0, 0, 0)
6 | const intensity = Color.of(1, 1, 1)
7 | const light = new PointLight(position, intensity)
8 | t.deepEqual(light.position, position)
9 | t.deepEqual(light.intensity, intensity)
10 | })
11 |
--------------------------------------------------------------------------------
/test/materials.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { Material, Color, Point, Vector, PointLight, Stripe } from "../src/models"
3 |
4 | test("the default material", t => {
5 | const m = Material.create()
6 | t.deepEqual(m.color, Color.of(1, 1, 1))
7 | t.is(m.ambient, 0.1)
8 | t.is(m.diffuse, 0.9)
9 | t.is(m.specular, 0.9)
10 | t.is(m.shininess, 200)
11 | t.is(m.reflective, 0.0)
12 | t.is(m.transparency, 0.0)
13 | t.is(m.refractive, 1.0)
14 | })
15 |
16 | test("lighting with the eye between the light and the surface", t => {
17 | const m = Material.create()
18 | const point = Point(0, 0, 0)
19 |
20 | const eyev = Vector(0, 0, -1)
21 | const normalv = Vector(0, 0, -1)
22 | const light = new PointLight(Point(0, 0, -10), Color.of(1, 1, 1))
23 |
24 | const result = m.lighting({ light, point, eyev, normalv })
25 | t.deepEqual(result.fixed, Color.of(1.9, 1.9, 1.9))
26 | })
27 |
28 | test("lighting with the eye between light and surface, eye offset 45°", t => {
29 | const m = Material.create()
30 | const point = Point(0, 0, 0)
31 |
32 | const eyev = Vector(0, Math.SQRT2 / 2, -Math.SQRT2 / 2)
33 | const normalv = Vector(0, 0, -1)
34 | const light = new PointLight(Point(0, 0, -10), Color.of(1, 1, 1))
35 |
36 | const result = m.lighting({ light, point, eyev, normalv })
37 | t.deepEqual(result.fixed, Color.of(1.0, 1.0, 1.0))
38 | })
39 |
40 | test("lighting with eye opposite surface, light offset 45°", t => {
41 | const m = Material.create()
42 | const point = Point(0, 0, 0)
43 |
44 | const eyev = Vector(0, 0, -1)
45 | const normalv = Vector(0, 0, -1)
46 | const light = new PointLight(Point(0, 10, -10), Color.of(1, 1, 1))
47 |
48 | const result = m.lighting({ light, point, eyev, normalv })
49 | t.deepEqual(result.fixed, Color.of(0.7364, 0.7364, 0.7364))
50 | })
51 |
52 | test("lighting with eye in the path of the reflection vector", t => {
53 | const m = Material.create()
54 | const point = Point(0, 0, 0)
55 |
56 | const eyev = Vector(0, -Math.SQRT2 / 2, -Math.SQRT2 / 2)
57 | const normalv = Vector(0, 0, -1)
58 | const light = new PointLight(Point(0, 10, -10), Color.of(1, 1, 1))
59 |
60 | const result = m.lighting({ light, point, eyev, normalv })
61 | t.deepEqual(result.fixed, Color.of(1.6364, 1.6364, 1.6364))
62 | })
63 |
64 | test("lighting with the light behind the surface", t => {
65 | const m = Material.create()
66 | const point = Point(0, 0, 0)
67 |
68 | const eyev = Vector(0, 0, -1)
69 | const normalv = Vector(0, 0, -1)
70 | const light = new PointLight(Point(0, 0, 10), Color.of(1, 1, 1))
71 |
72 | const result = m.lighting({ light, point, eyev, normalv })
73 | t.deepEqual(result, Color.of(0.1, 0.1, 0.1))
74 | })
75 |
76 | test("lighting with the the surface in shadow", t => {
77 | const m = Material.create()
78 | const point = Point(0, 0, 0)
79 |
80 | const eyev = Vector(0, 0, -1)
81 | const normalv = Vector(0, 0, -1)
82 | const light = new PointLight(Point(0, 0, -10), Color.of(1, 1, 1))
83 | const shadowed = true
84 |
85 | const result = m.lighting({ light, point, eyev, normalv, shadowed })
86 | t.deepEqual(result, Color.of(0.1, 0.1, 0.1))
87 | })
88 |
89 | test("lighting with a pattern applied", t => {
90 | const pattern = Stripe.of(Color.WHITE, Color.BLACK)
91 | const m = Material.create({ pattern, ambient: 1, diffuse: 0, specular: 0 })
92 |
93 | const eyev = Vector(0, 0, -1)
94 | const normalv = Vector(0, 0, -1)
95 | const light = new PointLight(Point(0, 0, -10), Color.of(1, 1, 1))
96 | const shadowed = false
97 |
98 | const c1 = m.lighting({ point: Point(0.9, 0, 0), light, eyev, normalv, shadowed })
99 | const c2 = m.lighting({ point: Point(1.1, 0, 0), light, eyev, normalv, shadowed })
100 |
101 | t.deepEqual(c1, Color.WHITE)
102 | t.deepEqual(c2, Color.BLACK)
103 | })
104 |
--------------------------------------------------------------------------------
/test/matrices.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { Matrix, Tuple, Point, Vector } from "../src/models"
3 |
4 | test("constructing and inspecting a 4x4 matrix", t => {
5 | const m = Matrix.of(
6 | [ 1 , 2, 3, 4 ],
7 | [ 5.5, 6.5, 7.5, 8.5 ],
8 | [ 9 , 10, 11, 12 ],
9 | [ 13.5, 14.5, 15.5, 16.5 ],
10 | )
11 | t.is(m[0][0], 1)
12 | t.is(m[0][3], 4)
13 | t.is(m[1][0], 5.5)
14 | t.is(m[1][2], 7.5)
15 | t.is(m[2][2], 11)
16 | t.is(m[3][0], 13.5)
17 | t.is(m[3][2], 15.5)
18 | })
19 |
20 | test("a 2x2 matrix ought to be representable", t => {
21 | const m = Matrix.of(
22 | [ -3, 5 ],
23 | [ 1, -2 ],
24 | )
25 | t.is(m[0][0], -3)
26 | t.is(m[0][1], 5)
27 | t.is(m[1][0], 1)
28 | t.is(m[1][1], -2)
29 | })
30 |
31 | test("a 3x3 matrix ought to be representable", t => {
32 | const m = Matrix.of(
33 | [ -3, 5, 0 ],
34 | [ 1, -2, -7 ],
35 | [ 0, 1, 1 ],
36 | )
37 | t.is(m[0][0], -3)
38 | t.is(m[1][1], -2)
39 | t.is(m[2][2], 1)
40 | })
41 |
42 | test("multiplying two matrices", t => {
43 | const a = Matrix.of(
44 | [ 1, 2, 3, 4 ],
45 | [ 2, 3, 4, 5 ],
46 | [ 3, 4, 5, 6 ],
47 | [ 4, 5, 6, 7 ],
48 | )
49 | const b = Matrix.of(
50 | [ 0, 1, 2, 4 ],
51 | [ 1, 2, 4, 8 ],
52 | [ 2, 4, 8, 16 ],
53 | [ 4, 8, 16, 32 ],
54 | )
55 | t.deepEqual(a.multiplyBy(b),
56 | Matrix.of(
57 | [ 24, 49, 98, 196 ],
58 | [ 31, 64, 128, 256 ],
59 | [ 38, 79, 158, 316 ],
60 | [ 45, 94, 188, 376 ],
61 | )
62 | )
63 | })
64 |
65 | test("a matrix multiplied by a tuple", t => {
66 | const a = Matrix.of(
67 | [ 1, 2, 3, 4 ],
68 | [ 2, 4, 4, 2 ],
69 | [ 8, 6, 4, 1 ],
70 | [ 0, 0, 0, 1 ],
71 | )
72 | const b = Tuple.of(1, 2, 3, 1)
73 | t.deepEqual(a.multiplyBy(b), Tuple.of(18, 24, 33, 1))
74 | })
75 |
76 | test("multiplying a matrix by the identity", t => {
77 | const a = Matrix.of(
78 | [ 0, 1, 2, 4 ],
79 | [ 1, 2, 4, 8 ],
80 | [ 2, 4, 8, 16 ],
81 | [ 4, 8, 16, 32 ],
82 | )
83 | t.deepEqual(a.multiplyBy(Matrix.identity), a)
84 | })
85 |
86 | test("multiplying identity by a tuple", t => {
87 | const a = Tuple.of(1, 2, 3, 4)
88 | t.deepEqual(Matrix.identity.multiplyBy(a), a)
89 | })
90 |
91 | test("transposing a matrix", t => {
92 | t.deepEqual(
93 | Matrix.of(
94 | [ 0, 9, 3, 0 ],
95 | [ 9, 8, 0, 8 ],
96 | [ 1, 8, 5, 3 ],
97 | [ 0, 0, 5, 8 ],
98 | ).transpose,
99 | Matrix.of(
100 | [ 0, 9, 1, 0 ],
101 | [ 9, 8, 8, 0 ],
102 | [ 3, 0, 5, 5 ],
103 | [ 0, 8, 3, 8 ],
104 | )
105 | )
106 | })
107 |
108 | test("calculating the determinant of a 2x2 matrix", t => {
109 | const m = Matrix.of(
110 | [ 1, 5 ],
111 | [ -3, 2 ],
112 | )
113 | t.is(m.determinant, 17)
114 | })
115 |
116 | test("a submatrix of a 3x3 matrix is a 2x2 matrix", t => {
117 | const m = Matrix.of(
118 | [ 1, 5, 0 ],
119 | [ -3, 2, 7 ],
120 | [ 0, 6, -3 ],
121 | )
122 | t.deepEqual(m.submatrix(0, 2),
123 | Matrix.of(
124 | [ -3, 2 ],
125 | [ 0, 6 ],
126 | )
127 | )
128 | })
129 |
130 | test("a submatrix of a 4x4 matrix is 3x3 matrix", t => {
131 | const m = Matrix.of(
132 | [ -6, 1, 1, 6 ],
133 | [ -8, 5, 8, 6 ],
134 | [ -1, 0, 8, 2 ],
135 | [ -7, 1, -1, 1 ],
136 | )
137 | t.deepEqual(m.submatrix(2, 1),
138 | Matrix.of(
139 | [ -6, 1, 6 ],
140 | [ -8, 8, 6 ],
141 | [ -7, -1, 1 ],
142 | )
143 | )
144 | })
145 |
146 | test("calculating a minor of a 3x3 matrix", t => {
147 | const a = Matrix.of(
148 | [ 3, 5, 0 ],
149 | [ 2, -1, -7 ],
150 | [ 6, -1, 5 ],
151 | )
152 | const b = a.submatrix(1, 0)
153 | t.is(b.determinant, 25)
154 | t.is(a.minor(1, 0), 25)
155 | })
156 |
157 | test("calculating a cofactor of a 3x3 matrix", t => {
158 | const a = Matrix.of(
159 | [ 3, 5, 0 ],
160 | [ 2, -1, -7 ],
161 | [ 6, -1, 5 ],
162 | )
163 | t.is(a.minor(0, 0), -12)
164 | t.is(a.cofactor(0, 0), -12)
165 | t.is(a.minor(1, 0), 25)
166 | t.is(a.cofactor(1, 0), -25)
167 | })
168 |
169 | test("calculating the determinant of a 3x3 matrix", t => {
170 | const a = Matrix.of(
171 | [ 1, 2, 6 ],
172 | [ -5, 8, -4 ],
173 | [ 2, 6, 4 ],
174 | )
175 | t.is(a.cofactor(0, 0), 56)
176 | t.is(a.cofactor(0, 1), 12)
177 | t.is(a.cofactor(0, 2), -46)
178 | t.is(a.determinant, -196)
179 | })
180 |
181 | test("calculating the determinant of a 4x4 matrix", t => {
182 | const a = Matrix.of(
183 | [ -2, -8, 3, 5 ],
184 | [ -3, 1, 7, 3 ],
185 | [ 1, 2, -9, 6 ],
186 | [ -6, 7, 7, -9 ],
187 | )
188 | t.is(a.cofactor(0, 0), 690)
189 | t.is(a.cofactor(0, 1), 447)
190 | t.is(a.cofactor(0, 2), 210)
191 | t.is(a.cofactor(0, 3), 51)
192 | t.is(a.determinant, -4071)
193 | })
194 |
195 | test("testing an invertible matrix for invertibility", t => {
196 | const a = Matrix.of(
197 | [ 6, 4, 4, 4 ],
198 | [ 5, 5, 7, 6 ],
199 | [ 4, -9, 3, -7 ],
200 | [ 9, 1, 7, -6 ],
201 | )
202 | t.is(a.determinant, -2120)
203 | t.is(a.isInvertible, true)
204 | })
205 |
206 | test("testing an non-invertible matrix for invertibility", t => {
207 | const a = Matrix.of(
208 | [ -4, 3, -2, -3 ],
209 | [ 9, 6, 2, 6 ],
210 | [ 0, -5, 1, -5 ],
211 | [ 0, 0, 0, 0 ],
212 | )
213 | t.is(a.determinant, 0)
214 | t.is(a.isInvertible, false)
215 | })
216 |
217 | test("calculating the inverse of a matrix", t => {
218 | const a = Matrix.of(
219 | [ -5, 2, 6, -8 ],
220 | [ 1, -5, 1, 8 ],
221 | [ 7, 7, -6, -7 ],
222 | [ 1, -3, 7, 4 ],
223 | )
224 | const b = a.inverse
225 | t.is(a.determinant, 532)
226 | t.is(a.cofactor(2, 3), -160)
227 | t.is(b[3][2], -160 / 532)
228 | t.is(a.cofactor(3, 2), 105)
229 | t.is(b[2][3], 105 / 532)
230 | t.deepEqual(b.fixed,
231 | Matrix.of(
232 | [ 0.21805 , 0.45113 , 0.24060 , -0.04511 ],
233 | [ -0.80827 , -1.45677 , -0.44361 , 0.52068 ],
234 | [ -0.07895 , -0.22368 , -0.05263 , 0.19737 ],
235 | [ -0.52256 , -0.81391 , -0.30075 , 0.30639 ],
236 | )
237 | )
238 | })
239 |
240 | test("calculating the inverse of another matrix", t => {
241 | const a = Matrix.of(
242 | [ 8, -5, 9, 2 ],
243 | [ 7, 5, 6, 1 ],
244 | [ -6, 0, 9, 6 ],
245 | [ -3, 0, -9, -4 ],
246 | )
247 | t.deepEqual(a.inverse.fixed,
248 | Matrix.of(
249 | [ -0.15385, -0.15385, -0.28205, -0.53846 ],
250 | [ -0.07692, 0.12308, 0.02564, 0.03077 ],
251 | [ 0.35897, 0.35897, 0.43590, 0.92308 ],
252 | [ -0.69231, -0.69231, -0.76923, -1.92308 ],
253 | )
254 | )
255 | })
256 |
257 | test("calculating the inverse of a third matrix", t => {
258 | const a = Matrix.of(
259 | [ 9, 3, 0, 9 ],
260 | [ -5, -2, -6, -3 ],
261 | [ -4, 9, 6, 4 ],
262 | [ -7, 6, 6, 2 ],
263 | )
264 | t.deepEqual(a.inverse.fixed,
265 | Matrix.of(
266 | [ -0.04074, -0.07778, 0.14444, -0.22222 ],
267 | [ -0.07778, 0.03333, 0.36667, -0.33333 ],
268 | [ -0.02901, -0.14630, -0.10926, 0.12963 ],
269 | [ 0.17778, 0.06667, -0.26667, 0.33333 ],
270 | )
271 | )
272 | })
273 |
274 | test("multiplying a product by its inverse", t => {
275 | const a = Matrix.of(
276 | [ 3, -9, 7, 3 ],
277 | [ 3, -8, 2, -9 ],
278 | [ -4, 4, 4, 1 ],
279 | [ -6, 5, -1, 1 ],
280 | )
281 | const b = Matrix.of(
282 | [ 8, 2, 2, 2 ],
283 | [ 3, -1, 7, 0 ],
284 | [ 7, 0, 5, 4 ],
285 | [ 6, -2, 0, 5 ],
286 | )
287 | const c = a.multiplyBy(b)
288 | const d = c.multiplyBy(b.inverse)
289 | t.deepEqual(d.fixed, a)
290 | })
291 |
292 | test("the transformation matrix for the default orientation", t => {
293 | const from = Point(0, 0, 0)
294 | const to = Point(0, 0, -1)
295 | const up = Vector(0, 1, 0)
296 | const transform = Matrix.viewTransform(from, to, up)
297 | t.deepEqual(transform, Matrix.identity)
298 | })
299 |
300 | test("a view transformation matrix looking in positive z direction", t => {
301 | const from = Point(0, 0, 0)
302 | const to = Point(0, 0, 1)
303 | const up = Vector(0, 1, 0)
304 | const transform = Matrix.viewTransform(from, to, up)
305 | t.deepEqual(transform, Matrix.scaling(-1, 1, -1))
306 | })
307 |
308 | test("the view transformation moves the world", t => {
309 | const from = Point(0, 0, 8)
310 | const to = Point(0, 0, 0)
311 | const up = Vector(0, 1, 0)
312 | const transform = Matrix.viewTransform(from, to, up)
313 | t.deepEqual(transform, Matrix.translation(0, 0, -8))
314 | })
315 |
316 | test("an arbitrary view transformation", t => {
317 | const from = Point(1, 3, 2)
318 | const to = Point(4, -2, 8)
319 | const up = Vector(1, 1, 0)
320 | const transform = Matrix.viewTransform(from, to, up)
321 | t.deepEqual(transform.fixed,
322 | Matrix.of(
323 | [ -0.50709, 0.50709, 0.67612, -2.36643 ],
324 | [ 0.76772, 0.60609, 0.12122, -2.82843 ],
325 | [ -0.35857, 0.59761, -0.71714, 0.00000 ],
326 | [ 0.00000, 0.00000, 0.00000, 1.00000 ],
327 | )
328 | )
329 | })
330 |
--------------------------------------------------------------------------------
/test/patterns.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { Pattern, Stripe, Gradient, Ring, Checkers, Color, Point, Sphere, Matrix } from "../src/models"
3 |
4 | const { WHITE, BLACK } = Color
5 |
6 | test("the default pattern transformation", t => {
7 | const pattern = new Pattern
8 | t.deepEqual(pattern.transform, Matrix.identity)
9 | })
10 |
11 | test("assigning a transformation", t => {
12 | const pattern = new Pattern
13 | pattern.transform = Matrix.translation(1, 2, 3)
14 | t.deepEqual(pattern.transform, Matrix.translation(1, 2, 3))
15 | })
16 |
17 | test("pattern with an object transformation", t => {
18 | const pattern = new Pattern
19 | const shape = Sphere.create({ transform: Matrix.scaling(2, 2, 2) })
20 | const color = pattern.colorAtShape(shape, Point(2, 3, 4))
21 | t.deepEqual(color, Color.of(1, 1.5, 2))
22 | })
23 |
24 | test("pattern with a pattern transformation", t => {
25 | const pattern = new Pattern
26 | pattern.transform = Matrix.scaling(2, 2, 2)
27 | const shape = Sphere.create()
28 | const color = pattern.colorAtShape(shape, Point(2, 3, 4))
29 | t.deepEqual(color, Color.of(1, 1.5, 2))
30 | })
31 |
32 | test("pattern with both an object and a pattern transformation", t => {
33 | const pattern = new Pattern
34 | pattern.transform = Matrix.translation(0.5, 1, 1.5)
35 | const shape = Sphere.create({ transform: Matrix.scaling(2, 2, 2) })
36 | const color = pattern.colorAtShape(shape, Point(2.5, 3, 3.5))
37 | t.deepEqual(color, Color.of(0.75, 0.5, 0.25))
38 | })
39 |
40 | test("creating a stripe pattern", t => {
41 | const pattern = Stripe.of(WHITE, BLACK)
42 | t.is(pattern[0], WHITE)
43 | t.is(pattern[1], BLACK)
44 | })
45 |
46 | test("a stripe pattern is constant in y", t => {
47 | const pattern = Stripe.of(WHITE, BLACK)
48 | t.is(pattern.colorAt(Point(0, 0, 0)), WHITE)
49 | t.is(pattern.colorAt(Point(0, 1, 0)), WHITE)
50 | t.is(pattern.colorAt(Point(0, 2, 0)), WHITE)
51 | })
52 |
53 | test("a stripe pattern is constant in z", t => {
54 | const pattern = Stripe.of(WHITE, BLACK)
55 | t.is(pattern.colorAt(Point(0, 0, 0)), WHITE)
56 | t.is(pattern.colorAt(Point(0, 0, 1)), WHITE)
57 | t.is(pattern.colorAt(Point(0, 0, 2)), WHITE)
58 | })
59 |
60 | test("a stripe pattern alternates in x", t => {
61 | const pattern = Stripe.of(WHITE, BLACK)
62 | t.is(pattern.colorAt(Point(0, 0, 0)), WHITE)
63 | t.is(pattern.colorAt(Point(0.9, 0, 0)), WHITE)
64 | t.is(pattern.colorAt(Point(1, 0, 0)), BLACK)
65 | t.is(pattern.colorAt(Point(-0.1, 0, 0)), BLACK)
66 | t.is(pattern.colorAt(Point(-1, 0, 0)), BLACK)
67 | t.is(pattern.colorAt(Point(-1.1, 0, 0)), WHITE)
68 | })
69 |
70 | test("a gradient pattern linearly interpolates between colors", t => {
71 | const pattern = Gradient.of(BLACK, WHITE)
72 | t.deepEqual(pattern.colorAt(Point(0, 0, 0)), BLACK)
73 | t.deepEqual(pattern.colorAt(Point(0.25, 0, 0)), Color.of(0.25, 0.25, 0.25))
74 | t.deepEqual(pattern.colorAt(Point(0.5, 0, 0)), Color.of(0.5, 0.5, 0.5))
75 | t.deepEqual(pattern.colorAt(Point(0.75, 0, 0)), Color.of(0.75, 0.75, 0.75))
76 | })
77 |
78 | test("a ring pattern extends in both x and z", t => {
79 | const pattern = Ring.of(BLACK, WHITE)
80 | t.deepEqual(pattern.colorAt(Point(0, 0, 0)), BLACK)
81 | t.deepEqual(pattern.colorAt(Point(1, 0, 0)), WHITE)
82 | t.deepEqual(pattern.colorAt(Point(0, 0, 1)), WHITE)
83 | // 0.708 = just slightly more than √2/2
84 | t.deepEqual(pattern.colorAt(Point(0.708, 0, 0.708)), WHITE)
85 | })
86 |
87 | test("a checkers pattern repeats in x", t => {
88 | const pattern = Checkers.of(BLACK, WHITE)
89 | t.deepEqual(pattern.colorAt(Point(0, 0, 0)), BLACK)
90 | t.deepEqual(pattern.colorAt(Point(0.99, 0, 0)), BLACK)
91 | t.deepEqual(pattern.colorAt(Point(1.01, 0, 0)), WHITE)
92 | })
93 |
94 | test("a checkers pattern repeats in y", t => {
95 | const pattern = Checkers.of(BLACK, WHITE)
96 | t.deepEqual(pattern.colorAt(Point(0, 0, 0)), BLACK)
97 | t.deepEqual(pattern.colorAt(Point(0, 0.99, 0)), BLACK)
98 | t.deepEqual(pattern.colorAt(Point(0, 1.01, 0)), WHITE)
99 | })
100 |
101 | test("a checkers pattern repeats in z", t => {
102 | const pattern = Checkers.of(BLACK, WHITE)
103 | t.deepEqual(pattern.colorAt(Point(0, 0, 0)), BLACK)
104 | t.deepEqual(pattern.colorAt(Point(0, 0, 0.99)), BLACK)
105 | t.deepEqual(pattern.colorAt(Point(0, 0, 1.01)), WHITE)
106 | })
107 |
--------------------------------------------------------------------------------
/test/planes.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { Plane, Point, Vector, Ray } from "../src/models"
3 |
4 | test("the normal of a plane is constant everywhere", t => {
5 | const p = Plane.create()
6 | const n1 = p.normalAt(Point(0, 0, 0))
7 | const n2 = p.normalAt(Point(10, 0, -10))
8 | const n3 = p.normalAt(Point(-5, 0, 150))
9 | t.deepEqual(n1.fixed, Vector(0, 1, 0))
10 | t.deepEqual(n2.fixed, Vector(0, 1, 0))
11 | t.deepEqual(n3.fixed, Vector(0, 1, 0))
12 | })
13 |
14 | test("intersect with a ray parallel to the plane", t => {
15 | const p = Plane.create()
16 | const r = new Ray(Point(0, 10, 0), Vector(0, 0, 1))
17 | const xs = r.intersect(p)
18 | t.is(xs.length, 0)
19 | })
20 |
21 | test("intersect with a coplanar ray", t => {
22 | const p = Plane.create()
23 | const r = new Ray(Point(0, 0, 0), Vector(0, 0, 1))
24 | const xs = r.intersect(p)
25 | t.is(xs.length, 0)
26 | })
27 |
28 | test("a ray intersecting a plane from above", t => {
29 | const p = Plane.create()
30 | const r = new Ray(Point(0, 1, 0), Vector(0, -1, 0))
31 | const xs = r.intersect(p)
32 | t.is(xs.length, 1)
33 | t.is(xs[0].t, 1)
34 | t.is(xs[0].object, p)
35 | })
36 |
37 | test("a ray intersecting a plane from below", t => {
38 | const p = Plane.create()
39 | const r = new Ray(Point(0, -1, 0), Vector(0, 1, 0))
40 | const xs = r.intersect(p)
41 | t.is(xs.length, 1)
42 | t.is(xs[0].t, 1)
43 | t.is(xs[0].object, p)
44 | })
45 |
--------------------------------------------------------------------------------
/test/rays.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { Ray, Point, Vector, Matrix } from "../src/models"
3 |
4 | test("creating and querying a ray", t => {
5 | const origin = Point(1, 2, 3)
6 | const direction = Vector(4, 5, 6)
7 | const r = new Ray(origin, direction)
8 | t.is(r.origin, origin)
9 | t.is(r.direction, direction)
10 | })
11 |
12 | test("computing a point from a distance", t => {
13 | const r = new Ray(Point(2, 3, 4), Vector(1, 0, 0))
14 | t.deepEqual(r.position(0), Point(2, 3, 4))
15 | t.deepEqual(r.position(1), Point(3, 3, 4))
16 | t.deepEqual(r.position(-1), Point(1, 3, 4))
17 | t.deepEqual(r.position(2.5), Point(4.5, 3, 4))
18 | })
19 |
20 | test("translating a ray", t => {
21 | const r = new Ray(Point(1, 2, 3), Vector(0, 1, 0))
22 | const m = Matrix.translation(3, 4, 5)
23 | const r2 = r.transform(m)
24 | t.deepEqual(r2.origin, Point(4, 6, 8))
25 | t.deepEqual(r2.direction, Vector(0, 1, 0))
26 | })
27 |
28 | test("scaling a ray", t => {
29 | const r = new Ray(Point(1, 2, 3), Vector(0, 1, 0))
30 | const m = Matrix.scaling(2, 3, 4)
31 | const r2 = r.transform(m)
32 | t.deepEqual(r2.origin, Point(2, 6, 12))
33 | t.deepEqual(r2.direction, Vector(0, 3, 0))
34 | })
35 |
--------------------------------------------------------------------------------
/test/shapes.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { Shape, Point, Vector, Matrix, Material } from "../src/models"
3 |
4 | test("the default transformation", t => {
5 | const s = Shape.create()
6 | t.deepEqual(s.transform, Matrix.identity)
7 | })
8 |
9 | test("assigning a transformation", t => {
10 | const transform = Matrix.translation(2, 3, 4)
11 | const s = Shape.create({ transform })
12 | t.deepEqual(s.transform, transform)
13 | })
14 |
15 | test("the default material", t => {
16 | const s = Shape.create()
17 | t.deepEqual(s.material, Material.create())
18 | })
19 |
20 | test("assigning a material", t => {
21 | const s = Shape.create({ ambient: 1 })
22 | const m = Material.create({ ambient: 1 })
23 | t.deepEqual(s.material, m)
24 | })
25 |
26 | test("computing the normal on a translated shape", t => {
27 | const s = Shape.create({ transform: Matrix.translation(0, 1, 0) })
28 | const n = s.normalAt(Point(0, 1.70711, -0.70711))
29 | t.deepEqual(n.fixed, Vector(0, 0.70711, -0.70711))
30 | })
31 |
32 | test("computing the normal on a scaled shape", t => {
33 | const s = Shape.create({ transform: Matrix.scaling(1, 0.5, 1) })
34 | const n = s.normalAt(Point(0, Math.SQRT2 / 2, -Math.SQRT2 / 2))
35 | t.deepEqual(n.fixed, Vector(0, 0.97014, -0.24254))
36 | })
37 |
--------------------------------------------------------------------------------
/test/spheres.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { Ray, Sphere, Point, Vector, Matrix, Material } from "../src/models"
3 |
4 | test("a ray intersects a sphere at two points", t => {
5 | const r = new Ray(Point(0, 0, -5), Vector(0, 0, 1))
6 | const s = Sphere.create()
7 | const xs = r.intersect(s)
8 | t.is(xs.length, 2)
9 | t.is(xs[0].t, 4)
10 | t.is(xs[1].t, 6)
11 | t.is(xs[0].object, s)
12 | t.is(xs[1].object, s)
13 | })
14 |
15 | test("a ray intersects a sphere at a tangent", t => {
16 | const r = new Ray(Point(0, 1, -5), Vector(0, 0, 1))
17 | const s = Sphere.create()
18 | const xs = r.intersect(s)
19 | t.is(xs.length, 2)
20 | t.is(xs[0].t, 5)
21 | t.is(xs[1].t, 5)
22 | t.is(xs[0].object, s)
23 | t.is(xs[1].object, s)
24 | })
25 |
26 | test("a ray intersects misses a sphere", t => {
27 | const r = new Ray(Point(0, 2, -5), Vector(0, 0, 1))
28 | const s = Sphere.create()
29 | const xs = r.intersect(s)
30 | t.is(xs.length, 0)
31 | })
32 |
33 | test("a ray originates inside a sphere", t => {
34 | const r = new Ray(Point(0, 0, 0), Vector(0, 0, 1))
35 | const s = Sphere.create()
36 | const xs = r.intersect(s)
37 | t.is(xs.length, 2)
38 | t.is(xs[0].t, -1)
39 | t.is(xs[1].t, 1)
40 | t.is(xs[0].object, s)
41 | t.is(xs[1].object, s)
42 | })
43 |
44 | test("a sphere is behind a ray", t => {
45 | const r = new Ray(Point(0, 0, 5), Vector(0, 0, 1))
46 | const s = Sphere.create()
47 | const xs = r.intersect(s)
48 | t.is(xs.length, 2)
49 | t.is(xs[0].t, -6)
50 | t.is(xs[1].t, -4)
51 | t.is(xs[0].object, s)
52 | t.is(xs[1].object, s)
53 | })
54 |
55 | test("intersecting a scaled sphere with a ray", t => {
56 | const r = new Ray(Point(0, 0, -5), Vector(0, 0, 1))
57 | const s = Sphere.create({ transform: Matrix.scaling(2, 2, 2) })
58 | const xs = r.intersect(s)
59 | t.is(xs.length, 2)
60 | t.is(xs[0].t, 3)
61 | t.is(xs[1].t, 7)
62 | })
63 |
64 | test("intersecting a translated sphere with a ray", t => {
65 | const r = new Ray(Point(0, 0, -5), Vector(0, 0, 1))
66 | const s = Sphere.create({ transform: Matrix.translation(5, 0, 0) })
67 | const xs = r.intersect(s)
68 | t.is(xs.length, 0)
69 | })
70 |
71 | test("the normal on a sphere at a point on the x axis", t => {
72 | const s = Sphere.create()
73 | const n = s.normalAt(Point(1, 0, 0))
74 | t.deepEqual(n, Vector(1, 0, 0))
75 | })
76 |
77 | test("the normal on a sphere at a point on the y axis", t => {
78 | const s = Sphere.create()
79 | const n = s.normalAt(Point(0, 1, 0))
80 | t.deepEqual(n, Vector(0, 1, 0))
81 | })
82 |
83 | test("the normal on a sphere at a point on the z axis", t => {
84 | const s = Sphere.create()
85 | const n = s.normalAt(Point(0, 0, 1))
86 | t.deepEqual(n, Vector(0, 0, 1))
87 | })
88 |
89 | test("the normal on a sphere at a non-axial point", t => {
90 | const s = Sphere.create()
91 | const n = s.normalAt(Point(Math.sqrt(3) / 3, Math.sqrt(3) / 3, Math.sqrt(3) / 3))
92 | t.deepEqual(n, Vector(Math.sqrt(3) / 3, Math.sqrt(3) / 3, Math.sqrt(3) / 3))
93 | })
94 |
95 | test("the normal is a normalized vector", t => {
96 | const s = Sphere.create()
97 | const n = s.normalAt(Point(Math.sqrt(3) / 3, Math.sqrt(3) / 3, Math.sqrt(3) / 3))
98 | t.deepEqual(n, n.normalize)
99 | })
100 |
101 | test("the default material for a glass sphere", t => {
102 | const m = Sphere.glass().material
103 | t.is(m.transparency, 1.0)
104 | t.is(m.refractive, 1.5)
105 | })
106 |
107 | test("the material for a glass sphere can be assigned", t => {
108 | const m = Sphere.glass({ refractive: 2.0 }).material
109 | t.is(m.transparency, 1.0)
110 | t.is(m.refractive, 2.0)
111 | })
112 |
--------------------------------------------------------------------------------
/test/transformations.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { Matrix, Point, Vector } from "../src/models"
3 |
4 | test("multiplying by a translation matrix", t => {
5 | const transform = Matrix.translation(5, -3, 2)
6 | const point = Point(-3, 4, 5)
7 | t.deepEqual(transform.multiplyBy(point), Point(2, 1, 7))
8 | })
9 |
10 | test("multiplying by the inverse of a translation matrix", t => {
11 | const transform = Matrix.translation(5, -3, 2)
12 | const point = Point(-3, 4, 5)
13 | t.deepEqual(transform.inverse.multiplyBy(point), Point(-8, 7, 3))
14 | })
15 |
16 | test("translation does not affect vectors", t => {
17 | const transform = Matrix.translation(5, -3, 2)
18 | const vector = Vector(-3, 4, 5)
19 | t.deepEqual(transform.multiplyBy(vector), vector)
20 | })
21 |
22 | test("a scaling matrix applied to a point", t => {
23 | const transform = Matrix.scaling(2, 3, 4)
24 | const point = Point(-4, 6, 8)
25 | t.deepEqual(transform.multiplyBy(point), Point(-8, 18, 32))
26 | })
27 |
28 | test("a scaling matrix applied to a vector", t => {
29 | const transform = Matrix.scaling(2, 3, 4)
30 | const vector = Vector(-4, 6, 8)
31 | t.deepEqual(transform.multiplyBy(vector), Vector(-8, 18, 32))
32 | })
33 |
34 | test("multiplying by the inverse of a scaling matrix", t => {
35 | const transform = Matrix.scaling(2, 3, 4)
36 | const vector = Vector(-4, 6, 8)
37 | t.deepEqual(transform.inverse.multiplyBy(vector), Vector(-2, 2, 2))
38 | })
39 |
40 | test("reflection is scaling by a negative value", t => {
41 | const transform = Matrix.scaling(-1, 1, 1)
42 | const point = Point(2, 3, 4)
43 | t.deepEqual(transform.multiplyBy(point), Point(-2, 3, 4))
44 | })
45 |
46 | test("rotating a point around the x axis", t => {
47 | const point = Point(0, 1, 0)
48 | const halfQuarter = Matrix.rotationX(Math.PI / 4)
49 | const fullQuarter = Matrix.rotationX(Math.PI / 2)
50 | t.deepEqual(halfQuarter.multiplyBy(point).fixed, Point(0, Math.SQRT2 / 2, Math.SQRT2 / 2).fixed)
51 | t.deepEqual(fullQuarter.multiplyBy(point).fixed, Point(0, 0, 1).fixed)
52 | })
53 |
54 | test("the inverse of an x-rotation rotates in the opposite direction", t => {
55 | const point = Point(0, 1, 0 )
56 | const halfQuarter = Matrix.rotationX(Math.PI / 4)
57 | t.deepEqual(halfQuarter.inverse.multiplyBy(point).fixed, Point(0, Math.SQRT2 / 2, -Math.SQRT2 / 2).fixed)
58 | })
59 |
60 | test("rotating a point around the y axis", t => {
61 | const point = Point(0, 0, 1)
62 | const halfQuarter = Matrix.rotationY(Math.PI / 4)
63 | const fullQuarter = Matrix.rotationY(Math.PI / 2)
64 | t.deepEqual(halfQuarter.multiplyBy(point).fixed, Point(Math.SQRT2 / 2, 0, Math.SQRT2 / 2).fixed)
65 | t.deepEqual(fullQuarter.multiplyBy(point).fixed, Point(1, 0, 0).fixed)
66 | })
67 |
68 | test("rotating a point around the z axis", t => {
69 | const point = Point(0, 1, 0)
70 | const halfQuarter = Matrix.rotationZ(Math.PI / 4)
71 | const fullQuarter = Matrix.rotationZ(Math.PI / 2)
72 | t.deepEqual(halfQuarter.multiplyBy(point).fixed, Point(-Math.SQRT2 / 2, Math.SQRT2 / 2, 0).fixed)
73 | t.deepEqual(fullQuarter.multiplyBy(point).fixed, Point(-1, 0, 0).fixed)
74 | })
75 |
76 | test("shearing transformation moves x in proportion to z", t => {
77 | const transform = Matrix.shearing(0, 1, 0, 0, 0, 0)
78 | const point = Point(2, 3, 4)
79 | t.deepEqual(transform.multiplyBy(point), Point(6, 3, 4))
80 | })
81 |
82 | test("shearing transformation moves y in proportion to x", t => {
83 | const transform = Matrix.shearing(0, 0, 1, 0, 0, 0)
84 | const point = Point(2, 3, 4)
85 | t.deepEqual(transform.multiplyBy(point), Point(2, 5, 4))
86 | })
87 |
88 | test("shearing transformation moves y in proportion to z", t => {
89 | const transform = Matrix.shearing(0, 0, 0, 1, 0, 0)
90 | const point = Point(2, 3, 4)
91 | t.deepEqual(transform.multiplyBy(point), Point(2, 7, 4))
92 | })
93 |
94 | test("shearing transformation moves z in proportion to x", t => {
95 | const transform = Matrix.shearing(0, 0, 0, 0, 1, 0)
96 | const point = Point(2, 3, 4)
97 | t.deepEqual(transform.multiplyBy(point), Point(2, 3, 6))
98 | })
99 |
100 | test("shearing transformation moves z in proportion to y", t => {
101 | const transform = Matrix.shearing(0, 0, 0, 0, 0, 1)
102 | const point = Point(2, 3, 4)
103 | t.deepEqual(transform.multiplyBy(point), Point(2, 3, 7))
104 | })
105 |
106 | test("individual transformations are applied in sequence", t => {
107 | const p = Point(1, 0, 1)
108 | const A = Matrix.rotationX(Math.PI / 2)
109 | const B = Matrix.scaling(5, 5, 5)
110 | const C = Matrix.translation(10, 5, 7)
111 |
112 | // apply rotation first
113 | const p2 = A.multiplyBy(p)
114 | t.deepEqual(p2.fixed, Point(1, -1, 0).fixed)
115 |
116 | // then apply scaling
117 | const p3 = B.multiplyBy(p2)
118 | t.deepEqual(p3.fixed, Point(5, -5, 0).fixed)
119 |
120 | // then apply translation
121 | const p4 = C.multiplyBy(p3)
122 | t.deepEqual(p4.fixed, Point(15, 0, 7).fixed)
123 | })
124 |
125 | test("chained transformations must be applied in reverse order", t => {
126 | const p = Point(1, 0, 1)
127 | const A = Matrix.rotationX(Math.PI / 2)
128 | const B = Matrix.scaling(5, 5, 5)
129 | const C = Matrix.translation(10, 5, 7)
130 |
131 | const T = C.multiplyBy(B).multiplyBy(A)
132 | t.deepEqual(T.multiplyBy(p).fixed, Point(15, 0, 7).fixed)
133 | })
134 |
135 | test("chained transformations", t => {
136 | const p = Point(1, 0, 1)
137 |
138 | const A = Matrix.rotationX(Math.PI / 2)
139 | const B = Matrix.scaling(5, 5, 5)
140 | const C = Matrix.translation(10, 5, 7)
141 | const T = C.multiplyBy(B).multiplyBy(A)
142 |
143 | t.deepEqual(Matrix.transform({
144 | rotate: { x: 90 },
145 | scale: { x: 5, y: 5, z: 5 },
146 | move: { x: 10, y: 5, z: 7 },
147 | }), T)
148 |
149 | t.deepEqual(Matrix.transform({
150 | rotate: { x: 90 },
151 | scale: 5,
152 | move: { x: 10, y: 5, z: 7 },
153 | }), T)
154 | })
155 |
--------------------------------------------------------------------------------
/test/tuples.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { Tuple, Position, Point, Vector, Color } from "../src/models"
3 |
4 | test("position with w=1.0 is a point", t => {
5 | const a = Position.of(4.3, -4.2, 3.1, 1.0)
6 | t.is(a.x, 4.3)
7 | t.is(a.y, -4.2)
8 | t.is(a.z, 3.1)
9 | t.is(a.w, 1.0)
10 | t.is(a.isPoint, true)
11 | t.is(a.isVector, false)
12 | })
13 |
14 | test("position with w=0 is a vector", t => {
15 | const a = Position.of(4.3, -4.2, 3.1, 0.0)
16 | t.is(a.x, 4.3)
17 | t.is(a.y, -4.2)
18 | t.is(a.z, 3.1)
19 | t.is(a.w, 0.0)
20 | t.is(a.isPoint, false)
21 | t.is(a.isVector, true)
22 | })
23 |
24 | test("point describes positions with w=1", t => {
25 | t.deepEqual(Point(4, -4, 3), Position.of(4, -4, 3, 1))
26 | })
27 |
28 | test("vector describes positions with w=0", t => {
29 | t.deepEqual(Vector(4, -4, 3), Position.of(4, -4, 3, 0))
30 | })
31 |
32 | test("adding two tuples", t => {
33 | const a1 = Tuple.of(3, -2, 5, 1)
34 | const a2 = Tuple.of(-2, 3, 1, 0)
35 | t.deepEqual(a1.add(a2), Tuple.of(1, 1, 6, 1))
36 | })
37 |
38 | test("subtracting two points", t => {
39 | const p1 = Point(3, 2, 1)
40 | const p2 = Point(5, 6, 7)
41 | t.deepEqual(p1.subtract(p2), Vector(-2, -4, -6))
42 | })
43 |
44 | test("subtracting a vector from a point", t => {
45 | const p = Point(3, 2, 1)
46 | const v = Vector(5, 6, 7)
47 | t.deepEqual(p.subtract(v), Point(-2, -4, -6))
48 | })
49 |
50 | test("subtracting two vectors", t => {
51 | const v1 = Vector(3, 2, 1)
52 | const v2 = Vector(5, 6, 7)
53 | t.deepEqual(v1.subtract(v2), Vector(-2, -4, -6))
54 | })
55 |
56 | test("subtracting a vector from the zero vectors", t => {
57 | const zero = Vector(0, 0, 0)
58 | const v = Vector(1, -2, 3)
59 | t.deepEqual(zero.subtract(v), Vector(-1, 2, -3))
60 | })
61 |
62 | test("negating a tuple", t => {
63 | const a = Tuple.of(1, -2, 3, -4)
64 | t.deepEqual(a.negate, Tuple.of(-1, 2, -3, 4))
65 | })
66 |
67 | test("multiplying a tuple by a scalar", t => {
68 | const a = Tuple.of(1, -2, 3, -4)
69 | t.deepEqual(a.multiplyBy(3.5), Tuple.of(3.5, -7, 10.5, -14))
70 | })
71 |
72 | test("multiplying a tuple by a fraction", t => {
73 | const a = Tuple.of(1, -2, 3, -4)
74 | t.deepEqual(a.multiplyBy(0.5), Tuple.of(0.5, -1, 1.5, -2))
75 | })
76 |
77 | test("dividing a tuple by a scalar", t => {
78 | const a = Tuple.of(1, -2, 3, -4)
79 | t.deepEqual(a.divideBy(2), Tuple.of(0.5, -1, 1.5, -2))
80 | })
81 |
82 | test("magnitude of Vector(1, 0, 0)", t => {
83 | const v = Vector(1, 0, 0)
84 | t.is(v.magnitude, 1)
85 | })
86 |
87 | test("magnitude of Vector(0, 1, 0)", t => {
88 | const v = Vector(0, 1, 0)
89 | t.is(v.magnitude, 1)
90 | })
91 |
92 | test("magnitude of Vector(0, 0, 1)", t => {
93 | const v = Vector(0, 0, 1)
94 | t.is(v.magnitude, 1)
95 | })
96 |
97 | test("magnitude of Vector(1, 2, 3)", t => {
98 | const v = Vector(1, 2, 3)
99 | t.is(v.magnitude, Math.sqrt(14))
100 | })
101 |
102 | test("magnitude of Vector(-1, -2, -3)", t => {
103 | const v = Vector(-1, -2, -3)
104 | t.is(v.magnitude, Math.sqrt(14))
105 | })
106 |
107 | test("normalizing Vector(4, 0, 0) gives (1, 0, 0)", t => {
108 | const v = Vector(4, 0, 0)
109 | t.deepEqual(v.normalize, Vector(1, 0, 0))
110 | })
111 |
112 | test("normalizing Vector(1, 2, 3)", t => {
113 | const v = Vector(1, 2, 3)
114 | t.deepEqual(v.normalize, Vector(1 / Math.sqrt(14), 2 / Math.sqrt(14), 3 / Math.sqrt(14)))
115 | })
116 |
117 | test("magnitude of a normalized vector", t => {
118 | const v = Vector(1, 2, 3)
119 | t.is(v.normalize.magnitude, 1)
120 | })
121 |
122 | test("dot product of two tuples", t => {
123 | const a = Vector(1, 2, 3)
124 | const b = Vector(2, 3, 4)
125 | t.is(a.dotProduct(b), 20)
126 | })
127 |
128 | test("cross product of two vectors", t => {
129 | const a = Vector(1, 2, 3)
130 | const b = Vector(2, 3, 4)
131 | t.deepEqual(a.crossProduct(b), Vector(-1, 2, -1))
132 | t.deepEqual(b.crossProduct(a), Vector(1, -2, 1))
133 | })
134 |
135 | test("colors are (red, green, blue) tuples", t => {
136 | const c = Color.of(-0.5, 0.4, 1.7)
137 | t.is(c.red, -0.5)
138 | t.is(c.green, 0.4)
139 | t.is(c.blue, 1.7)
140 | })
141 |
142 | test("adding colors", t => {
143 | const c1 = Color.of(0.9, 0.6, 0.75)
144 | const c2 = Color.of(0.5, 0.1, 0.25)
145 | t.deepEqual(c1.add(c2), Color.of(1.4, 0.7, 1.0))
146 | })
147 |
148 | test("subtracting colors", t => {
149 | const c1 = Color.of(0.9, 0.6, 0.75)
150 | const c2 = Color.of(0.5, 0.1, 0.25)
151 | t.deepEqual(c1.subtract(c2), Color.of(0.4, 0.5, 0.5))
152 | })
153 |
154 | test("multiplying a color by a scalar", t => {
155 | const c = Color.of(0.2, 0.3, 0.4)
156 | t.deepEqual(c.multiplyBy(2), Color.of(0.4, 0.6, 0.8))
157 | })
158 |
159 | test("multiplying colors", t => {
160 | const c1 = Color.of(1, 0.2, 0.3)
161 | const c2 = Color.of(0.9, 1, 0.1)
162 | t.deepEqual(c1.multiplyBy(c2), Color.of(0.9, 0.2, 0.03))
163 | })
164 |
165 | test("reflecting a vector approaching at 45°", t => {
166 | const v = Vector(1, -1, 0)
167 | const n = Vector(0, 1, 0)
168 | const r = v.reflect(n)
169 | t.deepEqual(r.fixed, Vector(1, 1, 0).fixed)
170 | })
171 |
172 | test("reflecting a vector off a slanted surface", t => {
173 | const v = Vector(0, -1, 0)
174 | const n = Vector(Math.SQRT2 / 2, Math.SQRT2 / 2, 0)
175 | const r = v.reflect(n)
176 | t.deepEqual(r.fixed, Vector(1, 0, 0).fixed)
177 | })
178 |
--------------------------------------------------------------------------------
/test/world.test.js:
--------------------------------------------------------------------------------
1 | import test from "ava"
2 | import { World, PointLight, Point, Vector, Color, Sphere, Plane, Matrix, Ray, Intersections, Intersection, Pattern } from "../src/models"
3 |
4 | test("creating a world", t => {
5 | const w = new World
6 | t.is(w.length, 0)
7 | t.is(w.light, undefined)
8 | })
9 |
10 | test("the default world", t => {
11 | const light = new PointLight(Point(-10, 10, -10), Color.of(1, 1, 1))
12 |
13 | const s1 = Sphere.create({
14 | color: Color.of(0.8, 1.0, 0.6),
15 | diffuse: 0.7,
16 | specular: 0.2
17 | })
18 |
19 | const s2 = Sphere.create({
20 | transform: Matrix.scaling(0.5, 0.5, 0.5)
21 | })
22 |
23 | const w = World.default
24 | t.deepEqual(w.light, light)
25 | t.deepEqual(w[0], s1)
26 | t.deepEqual(w[1], s2)
27 | })
28 |
29 | test("intersect a world with a ray", t => {
30 | const world = World.default
31 | const ray = new Ray(Point(0, 0, -5), Vector(0, 0, 1))
32 | const xs = world.intersect(ray)
33 | t.is(xs.length, 4)
34 | t.is(xs[0].t, 4)
35 | t.is(xs[1].t, 4.5)
36 | t.is(xs[2].t, 5.5)
37 | t.is(xs[3].t, 6)
38 | })
39 |
40 | test("shading an intersection", t => {
41 | const world = World.default
42 | const ray = new Ray(Point(0, 0, -5), Vector(0, 0, 1))
43 | const shape = world[0]
44 | const hit = new Intersection(4, shape)
45 | hit.prepare(ray)
46 | const color = world.shade(hit)
47 | t.deepEqual(color.fixed, Color.of(0.38066, 0.47583, 0.2855))
48 | })
49 |
50 | test("shading an intersection from the inside", t => {
51 | const world = World.default
52 | world.light = new PointLight(Point(0, 0.25, 0), Color.of(1, 1, 1))
53 | const ray = new Ray(Point(0, 0, 0), Vector(0, 0, 1))
54 | const shape = world[1]
55 | const hit = new Intersection(0.5, shape)
56 | hit.prepare(ray)
57 | const color = world.shade(hit)
58 | t.deepEqual(color.fixed, Color.of(0.90498, 0.90498, 0.90498))
59 | })
60 |
61 | test("shading an intersection in shadow", t => {
62 | const s1 = Sphere.create()
63 | const s2 = Sphere.create({ transform: Matrix.translation(0, 0, 10) })
64 |
65 | const world = World.of(s1, s2)
66 | world.light = new PointLight(Point(0, 0, -10), Color.of(1, 1, 1))
67 |
68 | const ray = new Ray(Point(0, 0, 5), Vector(0, 0, 1))
69 | const hit = new Intersection(4, s2)
70 | hit.prepare(ray)
71 |
72 | const color = world.shade(hit)
73 | t.deepEqual(color.fixed, Color.of(0.1, 0.1, 0.1))
74 | })
75 |
76 | test("the color when a ray misses", t => {
77 | const world = World.default
78 | const ray = new Ray(Point(0, 0, -5), Vector(0, 1, 0))
79 | const color = world.colorAt(ray)
80 | t.deepEqual(color.fixed, Color.of(0, 0, 0))
81 | })
82 |
83 | test("the color when a ray hits", t => {
84 | const world = World.default
85 | const ray = new Ray(Point(0, 0, -5), Vector(0, 0, 1))
86 | const color = world.colorAt(ray)
87 | t.deepEqual(color.fixed, Color.of(0.38066, 0.47583, 0.2855))
88 | })
89 |
90 | test("there is no shadow when nothing is colinear with point and light", t => {
91 | const world = World.default
92 | const point = Point(0, 10, 0)
93 | t.is(world.isShadowed(point), false)
94 | })
95 |
96 | test("shadow when an object is between the point and the light", t => {
97 | const world = World.default
98 | const point = Point(10, -10, 10)
99 | t.is(world.isShadowed(point), true)
100 | })
101 |
102 | test("there is no shadow when an object is behind the light", t => {
103 | const world = World.default
104 | const point = Point(-20, 20, -20)
105 | t.is(world.isShadowed(point), false)
106 | })
107 |
108 | test("there is no shadow when an object is behind the point", t => {
109 | const world = World.default
110 | const point = Point(-2, 2, -2)
111 | t.is(world.isShadowed(point), false)
112 | })
113 |
114 | test("reflected color for non-reflective material", t => {
115 | const world = World.default
116 | const ray = new Ray(Point(0, 0, 0), Vector(0, 0, 1))
117 | const shape = world[1]
118 | shape.material.ambient = 1
119 | const hit = new Intersection(1, shape)
120 | hit.prepare(ray)
121 | const color = world.reflect(hit)
122 | t.deepEqual(color, Color.of(0, 0, 0))
123 | })
124 |
125 | test("reflected color for reflective material", t => {
126 | const world = World.default
127 | const shape = Plane.create({
128 | reflective: 0.5,
129 | transform: Matrix.translation(0, -1, 0)
130 | })
131 | const ray = new Ray(Point(0, 0, -3), Vector(0, -Math.SQRT2 / 2, Math.SQRT2 / 2))
132 | const hit = new Intersection(Math.SQRT2, shape)
133 | hit.prepare(ray)
134 | const color = world.reflect(hit)
135 | t.deepEqual(color.fixed, Color.of(0.19033, 0.23791, 0.14275))
136 | })
137 |
138 | test("reflected color at maximum rescursive depth", t => {
139 | const world = World.default
140 | const shape = Plane.create({
141 | reflective: 0.5,
142 | transform: Matrix.translation(0, -1, 0)
143 | })
144 | const ray = new Ray(Point(0, 0, -3), Vector(0, -Math.SQRT2 / 2, Math.SQRT2 / 2))
145 | const hit = new Intersection(Math.SQRT2, shape)
146 | hit.prepare(ray)
147 | const color = world.reflect(hit, 0)
148 | t.deepEqual(color, Color.of(0, 0, 0))
149 | })
150 |
151 | test("refracted color with opaque surface", t => {
152 | const world = World.default
153 | const ray = new Ray(Point(0, 0, -5), Vector(0, 0, 1))
154 | const xs = Intersections.of(
155 | new Intersection(4, world[0]),
156 | new Intersection(6, world[0]),
157 | )
158 | xs[0].prepare(ray, xs)
159 | const color = world.refract(xs[0])
160 | t.deepEqual(color.fixed, Color.of(0, 0, 0))
161 | })
162 |
163 | test("refracted color at maximum rescursive depth", t => {
164 | const world = World.default
165 | world[0].material.transparency = 1.0
166 | world[0].material.refractive = 1.5
167 | const ray = new Ray(Point(0, 0, Math.SQRT2 / 2), Vector(0, 1, 0))
168 | const xs = Intersections.of(
169 | new Intersection(-Math.SQRT2 / 2, world[0]),
170 | new Intersection(-Math.SQRT2 / 2, world[1]),
171 | )
172 | xs[1].prepare(ray, xs)
173 | const color = world.refract(xs[1], 0)
174 | t.deepEqual(color, Color.of(0, 0, 0))
175 | })
176 |
177 | test("refracted color under total internal reflection", t => {
178 | const world = World.default
179 | world[0].material.transparency = 1.0
180 | world[0].material.refractive = 1.5
181 | const ray = new Ray(Point(0, 0, -Math.SQRT2 / 2), Vector(0, 1, 0))
182 | const xs = Intersections.of(
183 | new Intersection(-Math.SQRT2 / 2, world[0]),
184 | new Intersection(Math.SQRT2 / 2, world[0]),
185 | )
186 | xs[1].prepare(ray, xs)
187 | const color = world.refract(xs[1])
188 | t.deepEqual(color, Color.of(0, 0, 0))
189 | })
190 |
191 | test("refracted color with refracted ray", t => {
192 | const world = World.default
193 | world[0].material.ambient = 1.0
194 | world[0].material.pattern = new Pattern
195 | world[1].material.transparency = 1.0
196 | world[1].material.refractive = 1.5
197 | const ray = new Ray(Point(0, 0, 0.1), Vector(0, 1, 0))
198 | const xs = Intersections.of(
199 | new Intersection(-0.9899, world[0]),
200 | new Intersection(-0.4899, world[1]),
201 | new Intersection( 0.4899, world[1]),
202 | new Intersection( 0.9899, world[0]),
203 | )
204 | xs[2].prepare(ray, xs)
205 | const color = world.refract(xs[2])
206 | t.deepEqual(color.fixed, Color.of(0, 0.99888, 0.04722))
207 | })
208 |
209 | test("shade color for reflective material", t => {
210 | const world = World.default
211 | const shape = Plane.create({
212 | reflective: 0.5,
213 | transform: Matrix.translation(0, -1, 0)
214 | })
215 | const ray = new Ray(Point(0, 0, -3), Vector(0, -Math.SQRT2 / 2, Math.SQRT2 / 2))
216 | const hit = new Intersection(Math.SQRT2, shape)
217 | hit.prepare(ray)
218 | const color = world.shade(hit)
219 | t.deepEqual(color.fixed, Color.of(0.87676, 0.92434, 0.82917))
220 | })
221 |
222 | test("shade color with transparent material", t => {
223 | const world = World.default
224 | const floor = Plane.create({
225 | transform: Matrix.translation(0, -1, 0),
226 | transparency: 0.5,
227 | refractive: 1.5
228 | })
229 | world.push(floor)
230 | const ball = Sphere.create({
231 | color: Color.of(1, 0, 0),
232 | ambient: 0.5,
233 | transform: Matrix.translation(0, -3.5, -0.5)
234 | })
235 | world.push(ball)
236 | const ray = new Ray(Point(0, 0, -3), Vector(0, -Math.SQRT2 / 2, Math.SQRT2 / 2))
237 | const xs = Intersections.of(
238 | new Intersection(Math.SQRT2, floor)
239 | )
240 | xs[0].prepare(ray, xs)
241 | const color = world.shade(xs[0])
242 | t.deepEqual(color.fixed, Color.of(0.93643, 0.68643, 0.68643))
243 | })
244 |
245 | test("shade color with reflective, transparent material", t => {
246 | const world = World.default
247 | const floor = Plane.create({
248 | transform: Matrix.translation(0, -1, 0),
249 | reflective: 0.5,
250 | transparency: 0.5,
251 | refractive: 1.5
252 | })
253 | world.push(floor)
254 | const ball = Sphere.create({
255 | color: Color.of(1, 0, 0),
256 | ambient: 0.5,
257 | transform: Matrix.translation(0, -3.5, -0.5)
258 | })
259 | world.push(ball)
260 | const ray = new Ray(Point(0, 0, -3), Vector(0, -Math.SQRT2 / 2, Math.SQRT2 / 2))
261 | const xs = Intersections.of(
262 | new Intersection(Math.SQRT2, floor)
263 | )
264 | xs[0].prepare(ray, xs)
265 | const color = world.shade(xs[0])
266 | t.deepEqual(color.fixed, Color.of(0.93392, 0.69643, 0.69243))
267 | })
268 |
269 | test("colorAt with mutually reflective surfaces", t => {
270 | const world = World.of(
271 | Plane.create({
272 | reflective: 1,
273 | transform: Matrix.translation(0, -1, 0)
274 | }),
275 | Plane.create({
276 | reflective: 1,
277 | transform: Matrix.translation(0, 1, 0)
278 | })
279 | )
280 | world.light = new PointLight(Point(-10, 10, -10), Color.WHITE)
281 | const ray = new Ray(Point(0, 0, 0), Vector(0, 1, 0))
282 | t.truthy(world.colorAt(ray))
283 | })
284 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require("webpack")
2 | const path = require("path")
3 |
4 | module.exports = {
5 | entry: {
6 | bundle: "./src/index.js"
7 | },
8 |
9 | output: {
10 | filename: "[name].js",
11 | path: path.resolve(__dirname, "public")
12 | },
13 |
14 | mode: "production",
15 | devtool: "source-map",
16 |
17 | module: {
18 | rules: [
19 | {
20 | test: /\.js$/,
21 | exclude: /node_modules/,
22 | use: "babel-loader",
23 | },
24 | {
25 | test: /\_worker\.js$/,
26 | exclude: /node_modules/,
27 | use: "worker-loader",
28 | }
29 | ]
30 | },
31 |
32 | plugins: [
33 | new webpack.ProvidePlugin(modelMapping())
34 | ]
35 | }
36 |
37 | function modelMapping() {
38 | const modelPath = path.resolve(__dirname, "src", "models")
39 | const modelNames = [
40 | "Camera",
41 | "Canvas",
42 | "Checkers",
43 | "Color",
44 | "Gradient",
45 | "Intersection",
46 | "Intersections",
47 | "Material",
48 | "Matrix",
49 | "Pattern",
50 | "Plane",
51 | "Point",
52 | "PointLight",
53 | "Position",
54 | "Ray",
55 | "Ring",
56 | "Shape",
57 | "Sphere",
58 | "Stripe",
59 | "Tuple",
60 | "Vector",
61 | "World"
62 | ]
63 |
64 | const result = {}
65 | modelNames.forEach(modelName => {
66 | result[modelName] = [modelPath, modelName]
67 | })
68 | return result
69 | }
70 |
--------------------------------------------------------------------------------