├── .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 |
2 |
3 | 6 | 7 |
8 |
    9 |
    10 | -------------------------------------------------------------------------------- /public/chapters/02.html: -------------------------------------------------------------------------------- 1 |
    2 | 5 | Download projectile.ppm 6 | 7 |
    8 |
    9 | -------------------------------------------------------------------------------- /public/chapters/04.html: -------------------------------------------------------------------------------- 1 |
    2 | 5 | 6 | 9 | 10 | Download clock.ppm 11 | 12 |
    13 |
    14 | -------------------------------------------------------------------------------- /public/chapters/05.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 | Transform 5 | 9 |
    10 | 14 |
    15 | 19 |
    20 | 24 |
    25 |
    26 | 27 |
    28 | 29 | Download sphere.ppm 30 |
    31 | -------------------------------------------------------------------------------- /public/chapters/06.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 | Material 5 | 9 |
    10 | 14 |
    15 | 19 |
    20 | 24 |
    25 |
    26 | Transform 27 | 31 |
    32 | 36 |
    37 | 41 |
    42 | 46 |
    47 |
    48 | 49 |

    50 |

    51 |

    Download sphere.ppm

    52 |
    53 | -------------------------------------------------------------------------------- /public/chapters/07.html: -------------------------------------------------------------------------------- 1 |
    2 |

    3 |

    4 |

    Download scene.ppm

    5 |
    6 | -------------------------------------------------------------------------------- /public/chapters/08.html: -------------------------------------------------------------------------------- 1 |
    2 |

    3 |

    4 |

    Download scene.ppm

    5 |
    6 | -------------------------------------------------------------------------------- /public/chapters/09.html: -------------------------------------------------------------------------------- 1 |
    2 |

    3 |

    4 |
    5 | -------------------------------------------------------------------------------- /public/chapters/10.html: -------------------------------------------------------------------------------- 1 |
    2 |

    3 |

    4 |
    5 | -------------------------------------------------------------------------------- /public/chapters/11.html: -------------------------------------------------------------------------------- 1 |
    2 |

    3 |

    4 |
    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 | 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 = `
  1. x: ${format(x)} y: ${format(y)}
  2. ` 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 | --------------------------------------------------------------------------------