├── README.md ├── game_lib.js ├── images ├── complex.png ├── raycasting.png ├── raytracing.png ├── simple.png └── stereogram.png ├── index.html ├── raycaster.js └── simple_raycaster.js /README.md: -------------------------------------------------------------------------------- 1 | # Random dot stereogram raycaster 2 | 3 | This is a real-time 3D engine (ray caster) that renders to single-image random-dot stereogram (the images made popular in the Magic Eye books). I wrote this because I was curious if the brain would be able to follow a stereogram in motion. Click on the screen and press 3 after the program starts to render in stereogram. Let me know what you think! 4 | 5 | # [Give it a try here](https://ammonb.github.io/stereogram-raycaster/) 6 | 7 | ## Controls 8 | 9 | The program initially renders in color. You can change the render mode (to color, depth map or stereogram) by pressing 1, 2 or 3. In the game, navigate with the arrow keys, and jump with the space bar. If you hold down the 'e' key, you can use the arrow keys to modify the block in front of you. 10 | 11 | 12 | ## Raycasting 13 | 14 | Raycasting is an algorithm for rending 3D (or pseudo-3D) geometry. It was made famous by Wolfenstein 3D and Doom. The core algorithm is delightfully simple (you can write a working renderer in about 20 lines). The best way to understand ray casting is to view it as a simplification of ray tracing. 15 | 16 | In ray tracing, rays are 'traced' from the location of a camera in a scene out into the world. One ray is calculated for each pixel in the output image. All the render needs to do, then, is calculate what color object each ray intersects first, and draw a pixel of that color. The following images (from Wikipedia) shows the idea. 17 | ![ray tracing](./images/raytracing.png "Ray tracing") 18 | This algorithm produces beautiful renderings, but is computationally expensive. 19 | 20 | Raycasting is an optimization on this idea. Rather than calculate a ray per pixel, a raycaster calculates a ray per column of pixels, and reconstructs the column of pixels by considering the length of the ray. As long as the geometry satisfies certain constraints, this works like a charm, and is dramatically faster. To understand how this works, imagine a simple block maze like Wolfenstein 3D. 21 | 22 | ![Simple raycaster](./images/simple.png "Simple raycaster") 23 | 24 | Notice that all walls are vertical and of constant height, with the camera at the midpoint on the walls. This means that the rendered image is vertically symmetrical. As we draw this image, than, all we need to know is the height and color of the line to draw in each column. By perspective math, this height is simply the original wall height divided by the distance to the wall (or length of the ray cast for each column). The following image shows this. 25 | 26 | ![ray casting](./images/raycasting.png "Ray casting") 27 | 28 | The rendering loop used to draw the above image looks like 29 | 30 | for (var x = 0; x < SCREEN_WIDTH; x++) { 31 | // camera_heading + FOV * x / SCREEN_WIDTH; 32 | var angle = angleForColumn(x); 33 | 34 | // calculate distance to wall from camera position at given angle 35 | var [color, distance] = castRay(angle); 36 | 37 | // draw wall slice 38 | var h = WALL_HEIGHT / distance; 39 | drawLine(x, SCREEN_HEIGHT/2 - h, x, SCREEN_HEIGHT/2 + h, color); 40 | } 41 | 42 | 43 | This basic idea can be extended to support arbitrary wall heights, horizontal surfaces, and vertical motion by the camera (as it is in the game Doom, and my raycaster above). To understand how this works, look at a snippet of the rendering loop above 44 | 45 | // draw wall slice 46 | var h = WALL_HEIGHT / distance; 47 | drawLine(x, SCREEN_HEIGHT/2 - h, x, SCREEN_HEIGHT/2 + h, color); 48 | 49 | This can be re-written 50 | 51 | var h1 = CAM_HEIGHT - WALL_HEIGHT / 2; 52 | var d1 = distance; 53 | 54 | var h2 = CAM_HEIGHT + WALL_HEIGHT / 2; 55 | var d2 = distance; 56 | 57 | var y1 = h1 / d1; 58 | var y2 = h2 / d2; 59 | 60 | drawLine(x, y1, x, y2, color); 61 | 62 | Here we have two points intersecting the ray we've cast, essentially defined in cylindrical coordinates (the angle of the ray, the distance to each point, and the height of each point). We then convert both points to screen coordinates by dividing the height by the distance, and draw a line between them. This works for the top and bottom of a wall (as we've already seen). But it works equally well for horizontal surfaces. Occlusion obviously becomes an issue, but this is easily handled by drawing back to front (or drawing front to back, disallowing transparency, and clamping all y values at the min seen so far). 63 | 64 | ![Complex raycaster](./images/complex.png "Complex raycaster") 65 | 66 | I've totally ignored the issue of how you actually calculate ray/world intersections. You can read more about that [here] (http://lodev.org/cgtutor/raycasting.html). 67 | 68 | 69 | ## Random dot stereograms 70 | 71 | Stereograms are images (or pairs of images) that provide the illusion of a 3D scene with depth perception. The simplest way to do this is to show a separate image to each eye. Then, differences in the location of features in each image can provoke depth perception. This works, but requires an optical apparatus (like Google cardboard, or polarized light and 3D glasses) to view the two images. 72 | 73 | Random dot stereograms are stereograms where the images are seemingly random patterns of dots. Each image by itself shows nothing. However, a pair of random-dot images viewed as a stereogram can still provoke depth perception. Differences in the positions of the almost-random dots create depth perception without any color information. 74 | 75 | Random dot stereograms do not require a pair of images. This is the idea of a single-image random dot stereogram. Such an image uses a repetitive pattern of dots, similar to a chain link fence. The viewer can then spread or cross their eyes when viewing the image, and trick their brain into thinking that both eyes are focusing on the same spot when they are in fact offset by the width of the repeating pattern. Modifications to successive columns of the pattern can then create different angles between apparent features, and provoke depth perception. 76 | 77 | To understand the algorithm, consider the following diagram. 78 | 79 | ![Stereogram diagram](./images/stereogram.png "Stereogram diagram") 80 | 81 | This diagram shows a pair of eyes focusing on a 3D scene. Between the eyes and the scene, we again have an image plane (just as we did for the raycaster). However, this time, the goal is represent not color information, but stereoscopic depth information. How can we do this? Consider points A and B in the diagram. These are points on the surface of the 3D geometry that we are rendering. When both eyes focus on point A, the line from each eye passes through a different point in the image plane. Specifically, the line from the left eye passes through the point a1, and the line from the right eye passes through a2. If our image represents this scene, then, p1 and p2 must have the same color. Now consider point B on the scene. Again, the lines from the eyes pass through two points (b1 and b2). So points b1 and b2 must be the same color. We can repeat this exercise for every point on the surface of the geometry we are rendering, and the result will be a list of constraints on the output image (pairs of pixels that must be equal). All it takes to render a stereogram is to produce an image that satisfies these constraints (the degenerate case of setting all pixels to the same color is not a very interesting, however). 82 | 83 | Solving the constraints is not hard. However, it will be a per-pixel operation (like ray tracing) not a per-column operation like raycasting. First, we need a way to calculate the distance between pairs of linked pixels, as a function of the z value at a given point (the values x and y from the diagram). By similar triangles, we get 84 | 85 | constraint_separation = (EYE_SEPERATION * z) / (1 + z) 86 | 87 | where EYE_SEPERATION is the distance between our eyes (in pixels), and z is the depth of the scene at the point in question, measured in distance from the image plane, in multiples of the distance from our eyes to the image plane. 88 | 89 | Because all constraints are between pairs of pixels in the same row, we can consider the algorithm one row at a time. For each row of pixels in the image, then, we create an array to store constraints, where we map each index pixel to an earlier pixel 90 | 91 | var constraints = Array(SCREEN_WIDTH); 92 | 93 | And fill in the constraints as follows 94 | 95 | for (var x = 0; x < SCREEN_WIDTH; x++) { 96 | // get the z for xth pixel in the row 97 | var z = getZ(x, row); 98 | 99 | // distance between the image points linked by this z value 100 | var separation = (EYE_SEPERATION * z) / (1 + z); 101 | 102 | // the two linked points 103 | var p1 = x - Math.floor(separation/2); 104 | var p2 = p1 + Math.floor(separation); 105 | 106 | // if they're in range, record that p2 must equal p1 107 | if (p1 >= 0 && p2 < SCREEN_WIDTH) { 108 | constraints[p2] = p1; 109 | } 110 | } 111 | 112 | Then, all we have to do is scan left to right, and check the constraints. If a pixel is unconstrained, set it to a random value. Otherwise, copy the value from earlier in the image 113 | 114 | for (var x = 0; x < SCREEN_WIDTH; x++) { 115 | if (constraints[x] === undefined) { 116 | putPixel(x, y, randomColor()); 117 | } else { 118 | 119 | // get the color of the pixel pointed to by our constraint 120 | var c = getPixel(constraints[x], y); 121 | 122 | // and output that color 123 | putPixel(x, y, c); 124 | } 125 | } 126 | 127 | That's it! You can read in more detail about stereograms [here](http://www.cs.waikato.ac.nz/~ihw/papers/94-HWT-SI-IHW-SIRDS-paper.pdf). 128 | -------------------------------------------------------------------------------- /game_lib.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is a simple library for learning to program games in JavaScript. 3 | The goal is to hide as much complexity / housekeeping as possible, 4 | and just expose a simple drawing functions. I wrote this for a class 5 | that I taught at a public library. My goal was to simulate the 6 | simplicity of the QBasic environment where I learned to program. 7 | */ 8 | 9 | 10 | class ALGame { 11 | constructor(width=600, height=600) { 12 | // Save the width and height of the display 13 | this.width = width; 14 | this.height = height; 15 | 16 | // Create the canvas for our display, set width and height, and add to the body of the page 17 | this.canvas = document.createElement("canvas"); 18 | this.canvas.id = "ALGameCanvas"; 19 | this.canvas.width = width; 20 | this.canvas.height = height; 21 | 22 | document.getElementsByTagName('body')[0].appendChild(this.canvas); 23 | 24 | this.hiddenCanvas = document.createElement("canvas"); 25 | this.hiddenCanvas.width = width; 26 | this.hiddenCanvas.height = height; 27 | this.backgroundColor = "red"; 28 | 29 | this.ctx = this.hiddenCanvas.getContext("2d"); 30 | 31 | this.game_states = [] 32 | 33 | // Used to track how long the system takes to render a frame 34 | this.render_time = 0; 35 | 36 | // Track what keys are down 37 | this.pressed_keys = {}; 38 | this.pressed_fetch_times = {}; 39 | 40 | // Cache images by URL 41 | this.image_cache = {} 42 | 43 | this.last_text_y = 0; 44 | this.last_text_x = 0; 45 | 46 | // Cache audio objects by URL 47 | this.audio_cache = {} 48 | 49 | this.draw_fps = false; 50 | } 51 | 52 | addGameState(render_callback) { 53 | this.game_states.push(render_callback); 54 | } 55 | 56 | advanceState() { 57 | this.game_states.shift(); 58 | } 59 | 60 | setBackgroundColor(color) { 61 | this.backgroundColor = color; 62 | this.canvas.style.backgroundColor = this.hiddenCanvas.style.backgroundColor = color; 63 | } 64 | 65 | start(framerate = 30) { 66 | // register key press handlers 67 | self = this; 68 | window.addEventListener('keydown', function(e) { 69 | self.pressed_keys[e.keyCode] = true; 70 | }, false); 71 | 72 | window.addEventListener('keyup', function(e) { 73 | self.pressed_keys[e.keyCode] = false; 74 | }, false); 75 | 76 | 77 | // call the frame update delegate 78 | function update() { 79 | var start = Date.now(); 80 | 81 | if (self.game_states.length > 0) { 82 | 83 | self.last_text_y = 0; 84 | self.last_text_x = 0; 85 | 86 | self.game_states[0](); 87 | 88 | if (self.draw_fps) { 89 | self.drawText("FPS: " + (Math.floor(1000 / self.render_time)), {x:20, y:70}); 90 | } 91 | 92 | var ctx = self.canvas.getContext("2d"); 93 | ctx.drawImage(self.hiddenCanvas, 0, 0); 94 | } 95 | 96 | // Keep a running average of how long it takes to render each frame 97 | self.render_time = self.render_time * 0.5 + (Date.now() - start) * 0.5; 98 | 99 | // And compute a delay to hit our target Framerate 100 | var delay = Math.max(0, (1000 - (framerate*self.render_time)) / framerate); 101 | 102 | window.setTimeout(update, delay); 103 | } 104 | 105 | window.setTimeout(update, 1000/framerate); 106 | } 107 | 108 | isKeyPressed(c, repeat_limit=0) { 109 | var code; 110 | if (typeof c === "string") { 111 | code = c.toUpperCase().charCodeAt(0); 112 | } else { 113 | code = c; 114 | } 115 | if (this.pressed_keys[code]) { 116 | if (!this.pressed_fetch_times[code] || Date.now() - this.pressed_fetch_times[code] > repeat_limit) { 117 | this.pressed_fetch_times[code] = Date.now(); 118 | return true; 119 | } 120 | return false; 121 | } 122 | return false; 123 | } 124 | 125 | setLineWidth(width) { 126 | this.ctx.lineWidth=width; 127 | } 128 | 129 | color(r, g ,b) { 130 | return 'rgb(' + Math.floor(r) + ',' + Math.floor(g) + ',' + Math.floor(b) + ')'; 131 | } 132 | 133 | randInt(min, max) { 134 | return Math.floor(Math.random() * (max - min) + min); 135 | } 136 | 137 | randColor() { 138 | return this.color(this.randInt(0, 256), this.randInt(0, 256), this.randInt(0, 256)); 139 | } 140 | 141 | clearScreen() { 142 | self.drawRect(0, 0, self.width, self.height, this.backgroundColor); 143 | //self.ctx.clearRect(0, 0, self.width, self.height); 144 | } 145 | 146 | drawLine(x1, y1, x2, y2, color = "black") { 147 | this.ctx.beginPath(); 148 | this.ctx.moveTo(x1, y1); 149 | this.ctx.lineTo(x2, y2); 150 | this.ctx.strokeStyle = color; 151 | this.ctx.stroke(); 152 | } 153 | 154 | drawRect(x, y, width, height, color = "black") { 155 | this.ctx.fillStyle = color; 156 | this.ctx.fillRect(x, y, width, height); 157 | } 158 | 159 | drawCircle(x, y, radius, color = "black") { 160 | this.ctx.beginPath(); 161 | this.ctx.arc(x, y, radius, 0, 2 * Math.PI, false); 162 | this.ctx.fillStyle = color; 163 | this.ctx.fill(); 164 | } 165 | 166 | drawImageUrl(url, x, y, width, height) { 167 | var img = this.image_cache[url]; 168 | if (img === undefined) { 169 | img = new ALImage(url); 170 | this.image_cache[url] = img; 171 | } 172 | this.drawImage(img, x, y, width, height); 173 | } 174 | 175 | drawImage(image, x, y, width, height) { 176 | this.ctx.drawImage(image.img, x, y, width, height); 177 | } 178 | 179 | drawText(text, attributes={}) { 180 | var size = attributes.size || 30; 181 | var color = attributes.color || 'black'; 182 | var x = attributes.x || Number.MIN_VALUE; 183 | var y = attributes.y || Number.MIN_VALUE; 184 | var font = attributes.font || 'Courier'; 185 | var align = attributes.align || 'left'; 186 | 187 | if (y === Number.MIN_VALUE) { 188 | y = this.last_text_y = this.last_text_y + size * 1.25; 189 | } 190 | 191 | if (x == Number.MIN_VALUE) { 192 | x = this.last_text_x; 193 | } 194 | 195 | this.ctx.font = '' + size + 'pt ' + font; 196 | this.ctx.textAlign = align; 197 | this.ctx.fillStyle = color; 198 | this.ctx.fillText(text, x, y); 199 | } 200 | 201 | playSound(url, loop=false) { 202 | if (!this.audio_cache[url]) { 203 | this.audio_cache[url] = new Audio(url); 204 | } 205 | this.audio_cache[url].loop = loop; 206 | this.audio_cache[url].pause(); 207 | this.audio_cache[url].currentTime = 0; 208 | this.audio_cache[url].play(); 209 | } 210 | 211 | stopSound(url) { 212 | if (this.audio_cache[url]) { 213 | this.audio_cache[url].pause(); 214 | } 215 | } 216 | } 217 | 218 | class ALImage { 219 | constructor(url) { 220 | this.img = new Image(); 221 | this.img.src = url; 222 | } 223 | } 224 | 225 | const ARROW_UP = 38; 226 | const ARROW_DOWN = 40; 227 | const ARROW_LEFT = 37; 228 | const ARROW_RIGHT= 39; 229 | -------------------------------------------------------------------------------- /images/complex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ammonb/stereogram-raycaster/2f55ecd5604038fc5fa0f79e182c825979fea325/images/complex.png -------------------------------------------------------------------------------- /images/raycasting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ammonb/stereogram-raycaster/2f55ecd5604038fc5fa0f79e182c825979fea325/images/raycasting.png -------------------------------------------------------------------------------- /images/raytracing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ammonb/stereogram-raycaster/2f55ecd5604038fc5fa0f79e182c825979fea325/images/raytracing.png -------------------------------------------------------------------------------- /images/simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ammonb/stereogram-raycaster/2f55ecd5604038fc5fa0f79e182c825979fea325/images/simple.png -------------------------------------------------------------------------------- /images/stereogram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ammonb/stereogram-raycaster/2f55ecd5604038fc5fa0f79e182c825979fea325/images/stereogram.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Random dot stereogram raycaster 4 | 5 | 6 |

Random dot stereogram raycaster

7 |

Move with the arrow keys, jump with space. Press 1, 2, 3 and 4 to change the render mode, and 'e' + arrow keys to edit the world. There's also a Doom easter egg hidden in there :)

8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /raycaster.js: -------------------------------------------------------------------------------- 1 | var game = new ALGame(1000, 600); 2 | game.addGameState(updateScreen); 3 | game.addGameState(updateScreenGameOver); 4 | game.start(30); 5 | 6 | 7 | // the type of each block in the world 8 | var map_types = [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 9 | [1, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 1], 10 | [1, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 1], 11 | [1, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 1], 12 | [1, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 1], 13 | [1, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 1], 14 | [1, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 1], 15 | [1, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 1], 16 | [1, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 1], 17 | [1, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 1], 18 | [1, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 1], 19 | [1, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 1], 20 | [1, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 1], 21 | [1, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 5, 0, 1], 22 | [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]]; 23 | 24 | // the height of every block in the world 25 | var map_heights = [[ 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9], 26 | [ 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9], 27 | [ 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9], 28 | [ 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9], 29 | [ 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9], 30 | [ 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9], 31 | [ 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9], 32 | [ 9, 0, 0, 0, 2, 4, 6, 8, 0, 0, 0, 0, 0, 0, 9], 33 | [ 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9], 34 | [ 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9], 35 | [ 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9], 36 | [ 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9], 37 | [ 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9], 38 | [ 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9], 39 | [ 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9]]; 40 | 41 | 42 | // map of block types to colors and other properties 43 | const block_types = { 44 | 0: {color:[180,180,180]}, 45 | 1: {color:[0,0,190]}, 46 | 2: {color:[40,150,0]}, 47 | 3: {color:[100,0,190]}, 48 | 4: {color:[40,40,100]}, 49 | 5: {color:[90,30,30]}, 50 | 6: {color:[255,150,0], bounce:3.0}, 51 | 7: {color:[255,0, 0], poision:true} 52 | }; 53 | 54 | // how high the camera is positioned above the block it's standing on 55 | const PLAYER_HEIGHT = 3.5; 56 | 57 | const FIELD_OF_VIEW = 80; 58 | 59 | // vertical scale of the world. double to double the height of all walls 60 | const SCALE = 80; 61 | 62 | const WALK_SPEED = 0.01; 63 | 64 | 65 | // the largest step up our character can take (vertical walls higher than this will be collisions) 66 | const MAX_STEP_SIZE = 2.0; 67 | 68 | // stereograph params 69 | const DEPTH_OF_FIELD = 0.3; 70 | const EYE_SEPARATION = 200; 71 | 72 | 73 | for (var i = 255; i > 0; i--) { 74 | console.log("" + i + " ," + stereoSeperation(i / 255.0)); 75 | } 76 | 77 | // track camera location and state 78 | var cam_x = 5.0; 79 | var cam_y = 5.0; 80 | var cam_height = 0; 81 | var cam_angle = 0.1; 82 | 83 | // velocities 84 | var cam_vel_x = 0.0; 85 | var cam_vel_y = 0.0; 86 | var cam_vel_a = 0.0; 87 | var cam_vel_vert = 0; 88 | 89 | 90 | // more efficient way to represent camera orientation. 91 | var cam_dir_x, cam_dir_y, screen_dir_x, screen_dir_y; 92 | 93 | // x, y coords of block under erit 94 | var edit_x = -1; 95 | var edit_y = -1; 96 | 97 | 98 | // are re rendering colors, a depth map, or a stereograph 99 | var render_mode = 2; 100 | 101 | 102 | game.setLineWidth(1); 103 | game.setBackgroundColor('gray'); 104 | game.draw_fps = true; 105 | 106 | if (map_types.length != map_heights.length || map_types[0].length != map_heights[0].length) { 107 | console.error("Color map and height map need to be the same size"); 108 | } 109 | 110 | // this function is called once per game frame, 111 | function updateScreen() { 112 | game.clearScreen(); 113 | 114 | // either edit blocks, or move the player, based on what keys are pressed 115 | if (game.isKeyPressed('e')) { 116 | makeEdits(); 117 | } else { 118 | edit_x = edit_y = -1; 119 | handleControls(); 120 | } 121 | 122 | checkRenderMode(); 123 | 124 | // make the player move and bounce! 125 | applyPlayerPhysics(); 126 | 127 | // pre-calculate vectors that will be useful when rendering 128 | var radian_angle = degreesToRadians(cam_angle); 129 | cam_dir_x = Math.cos(radian_angle); 130 | cam_dir_y = Math.sin(radian_angle); 131 | 132 | // get the x and y components of the vector on which we are projecting the image. This is perpendicular to the camera vector, and with a length to give our correct FOV 133 | var screen_angle = radian_angle + Math.PI / 2; 134 | var s = Math.sin(degreesToRadians(FIELD_OF_VIEW/2))*2; 135 | screen_dir_x = Math.cos(screen_angle) * s; 136 | screen_dir_y = Math.sin(screen_angle) * s; 137 | 138 | // for each column of pixels on the screen, cast a ray and render! 139 | for (var column = 0; column < game.width; column++) { 140 | castRay(column); 141 | } 142 | if (render_mode == 2 || render_mode == 3) { 143 | convertToStereogram(); 144 | } 145 | } 146 | 147 | function checkRenderMode() { 148 | if (game.isKeyPressed('1')) { 149 | render_mode = 0; 150 | game.setBackgroundColor('gray'); 151 | } 152 | if (game.isKeyPressed('2')) { 153 | render_mode = 1; 154 | game.setBackgroundColor('black'); 155 | } 156 | if (game.isKeyPressed('3')) { 157 | render_mode = 2; 158 | game.setBackgroundColor('black'); 159 | } 160 | if (game.isKeyPressed('4')) { 161 | render_mode = 3; 162 | game.setBackgroundColor('black'); 163 | } 164 | } 165 | 166 | // check for key presses and update the player velocity 167 | function handleControls() { 168 | if (game.isKeyPressed(ARROW_LEFT)) { 169 | cam_vel_a -= 1; 170 | } 171 | 172 | if (game.isKeyPressed(ARROW_RIGHT)) { 173 | cam_vel_a += 1; 174 | } 175 | 176 | if (game.isKeyPressed(ARROW_UP)) { 177 | cam_vel_x += Math.cos(degreesToRadians(cam_angle)) * WALK_SPEED; 178 | cam_vel_y += Math.sin(degreesToRadians(cam_angle)) * WALK_SPEED; 179 | } 180 | 181 | if (game.isKeyPressed(ARROW_DOWN)) { 182 | cam_vel_x -= Math.cos(degreesToRadians(cam_angle)) * WALK_SPEED; 183 | cam_vel_y -= Math.sin(degreesToRadians(cam_angle)) * WALK_SPEED; 184 | } 185 | var h = cam_height - map_heights[Math.floor(cam_y)][Math.floor(cam_x)]; 186 | if (h < 0.1 && game.isKeyPressed(' ', 250)) { 187 | cam_vel_vert += 1.5; 188 | } 189 | } 190 | 191 | // check for key pressed in block edit mode, and edit the selected block 192 | function makeEdits() { 193 | edit_x = Math.floor(cam_x + Math.cos(degreesToRadians(cam_angle)) * 1.5); 194 | edit_y = Math.floor(cam_y + Math.sin(degreesToRadians(cam_angle)) * 1.5); 195 | if (isInBounds(edit_x, edit_y)) { 196 | if (game.isKeyPressed(ARROW_UP, 100)) { 197 | map_heights[edit_y][edit_x] += 0.5; 198 | } 199 | if (game.isKeyPressed(ARROW_DOWN, 100)) { 200 | map_heights[edit_y][edit_x] -= 0.5; 201 | } 202 | if (game.isKeyPressed(ARROW_LEFT, 300)) { 203 | map_types[edit_y][edit_x] += 1; 204 | if (map_types[edit_y][edit_x] >= Object.keys(block_types).length) { 205 | map_types[edit_y][edit_x] = 0; 206 | } 207 | } 208 | if (game.isKeyPressed(ARROW_RIGHT, 300)) { 209 | map_types[edit_y][edit_x] -= 1; 210 | if (map_types[edit_y][edit_x] < 0) { 211 | map_types[edit_y][edit_x] = Object.keys(block_types).length - 1; 212 | } 213 | } 214 | if (game.isKeyPressed(' ', 300)) { 215 | map_heights[edit_y][edit_x] = map_heights[Math.floor(cam_y)][Math.floor(cam_x)]; 216 | } 217 | } 218 | } 219 | 220 | // cast a ray and draw a single column of pixels 221 | function castRay(column) { 222 | 223 | var screen_x = (2 * column) / game.width - 1; 224 | 225 | var ray_pos_x = cam_x; 226 | var ray_pos_y = cam_y; 227 | 228 | var ray_dir_x = cam_dir_x + screen_dir_x * screen_x; 229 | var ray_dir_y = cam_dir_y + screen_dir_y * screen_x; 230 | 231 | var map_x = Math.floor(ray_pos_x); 232 | var map_y = Math.floor(ray_pos_y); 233 | 234 | 235 | 236 | // ray is cast using the DDA algorithm, as described at http://lodev.org/cgtutor/raycasting.html 237 | 238 | //x, y distance from current position to next block boundary 239 | var side_dist_x; 240 | var side_dist_y; 241 | 242 | //x, y distance from one block boundary to the next 243 | var delta_dist_x = Math.sqrt(1 + (ray_dir_y * ray_dir_y) / (ray_dir_x * ray_dir_x)); 244 | var delta_dist_y = Math.sqrt(1 + (ray_dir_x * ray_dir_x) / (ray_dir_y * ray_dir_y)); 245 | 246 | //track whether we're moving in a positive or negative direction on x and y axes 247 | var step_x; 248 | var step_y; 249 | 250 | // track if we hit a vertical or horizontal boundary 251 | var side; 252 | 253 | //calculate step and side distances 254 | if (ray_dir_x < 0) { 255 | step_x = -1; 256 | side_dist_x = (ray_pos_x - map_x) * delta_dist_x; 257 | } else { 258 | step_x = 1; 259 | side_dist_x = (map_x + 1.0 - ray_pos_x) * delta_dist_x; 260 | } 261 | 262 | if (ray_dir_y < 0) { 263 | step_y = -1; 264 | side_dist_y = (ray_pos_y - map_y) * delta_dist_y; 265 | } else { 266 | step_y = 1; 267 | side_dist_y = (map_y + 1.0 - ray_pos_y) * delta_dist_y; 268 | } 269 | 270 | var distance = 0.01; 271 | var height = map_heights[map_y][map_x]; 272 | var color = (render_mode === 0 ? map_types[map_y][map_x] : [255, 255, 255]); 273 | 274 | var min_y = game.height; 275 | 276 | var is_edit = false; 277 | 278 | // loop until we're out of range. to simplify floor drawing, we check mid loop 279 | while(true) { 280 | 281 | // get the distance for the next block boundary in the map 282 | var next_distance; 283 | if (side == 0) { 284 | next_distance = (map_x - ray_pos_x + (1 - step_x) / 2) / ray_dir_x; 285 | } else { 286 | next_distance = (map_y - ray_pos_y + (1 - step_y) / 2) / ray_dir_y; 287 | } 288 | if (next_distance < 0.01) next_distance = 0.01; 289 | 290 | // draw the floor! 291 | min_y = drawFloor(column, height - (cam_height + PLAYER_HEIGHT), distance, next_distance, block_types[color], is_edit, min_y); 292 | 293 | distance = next_distance; 294 | 295 | // break out of the loop if we've gone out of bounds 296 | if (!isInBounds(map_x, map_y)) { 297 | break; 298 | } 299 | 300 | // are we drawing the block currently selected for editing? 301 | is_edit = (edit_x == map_x && map_y == edit_y); 302 | 303 | // draw the wall! 304 | var next_color = (render_mode === 0 ? map_types[map_y][map_x] : [255, 255, 255]); 305 | next_height = map_heights[map_y][map_x]; 306 | if (next_height > height) { 307 | min_y = drawWall(column, height - (cam_height + PLAYER_HEIGHT), next_height - (cam_height + PLAYER_HEIGHT), distance, block_types[next_color], is_edit, side, min_y); 308 | } 309 | height = next_height; 310 | color = next_color; 311 | 312 | // and advance the ray in the map! 313 | if (side_dist_x < side_dist_y) { 314 | side_dist_x += delta_dist_x; 315 | map_x += step_x; 316 | side = 0; 317 | } else { 318 | side_dist_y += delta_dist_y; 319 | map_y += step_y; 320 | side = 1; 321 | } 322 | } 323 | 324 | 325 | } 326 | 327 | // function colorWithShade(color_components, shade) { 328 | // var v = (255*3) * shade; 329 | // c = [0, 0, 0]; 330 | // if (v > 255) { 331 | // c[0] = 255; 332 | // v -= 255; 333 | // } 334 | // if (v > 255) { 335 | // c[1] = 255; 336 | // v -= 255; 337 | // } 338 | // c[2] = Math.floor(v); 339 | // return game.color(c[0], c[1], c[2]); 340 | // } 341 | 342 | function colorWithShade(color_components, shade) { 343 | return game.color(color_components[0]*shade, color_components[1]*shade, color_components[2]*shade); 344 | } 345 | 346 | 347 | const MIN_LIGHT = 0.0; 348 | const MAX_LIGHT = 1.0; 349 | const MIN_LIGHT_DISTANCE = 20.0; 350 | 351 | function shadeForDistance(distance) { 352 | if (distance > MIN_LIGHT_DISTANCE) return MIN_LIGHT; 353 | return MAX_LIGHT + (MIN_LIGHT - MAX_LIGHT) * (distance / MIN_LIGHT_DISTANCE); 354 | } 355 | 356 | 357 | function shadeWall(distance, is_edit, side) { 358 | var shade = side === 1 ? 1.0 : 0.75; 359 | if (!is_edit) shade *= shadeForDistance(distance); 360 | return shade; 361 | } 362 | 363 | function shadeWall(distance, is_edit, side) { 364 | var shade = side === 1 ? 1.0 : 0.75; 365 | if (!is_edit) shade *= shadeForDistance(distance); 366 | return shade; 367 | } 368 | 369 | 370 | function drawWall(column, height1, height2, distance, color, is_edit, side, min_y) { 371 | if (render_mode === 0) { 372 | var shade = shadeWall(distance, is_edit, side); 373 | var c = colorWithShade(color.color, shade); 374 | } else { 375 | var shade = shadeForDistance(distance); 376 | var c = colorWithShade([255, 255, 255], shade) 377 | } 378 | 379 | var y1 = game.height / 2 - SCALE * height1 / distance; 380 | var y2 = game.height / 2 - SCALE * height2 / distance; 381 | 382 | y1 = Math.min(y1, min_y); 383 | min_y = Math.min(min_y, y2); 384 | 385 | if (y1 > y2) 386 | game.drawRect(column, y1, 1, y2 - y1, c); 387 | 388 | return min_y; 389 | } 390 | 391 | function drawFloor(column, height, distance1, distance2, color, is_edit, min_y) { 392 | if (render_mode === 0) { 393 | var shade = 0.85; 394 | if(!is_edit) shade *= shadeForDistance(distance1); 395 | var c = colorWithShade(color.color, shade); 396 | } else { 397 | var shade = shadeForDistance((distance1 + distance2) / 2); 398 | var c = colorWithShade([255, 255, 255], shade); 399 | } 400 | 401 | var y1 = game.height / 2 - SCALE * height / distance1; 402 | var y2 = game.height / 2 - SCALE * height / distance2; 403 | 404 | y1 = Math.min(y1, min_y); 405 | min_y = Math.min(min_y, y2); 406 | 407 | if (y1 > y2) 408 | game.drawRect(column, y1, 1, y2 - y1, c); 409 | 410 | return min_y; 411 | } 412 | 413 | // convert degrees to radians 414 | function degreesToRadians(d) { 415 | return (d * Math.PI) / 180.0; 416 | } 417 | 418 | function isInBounds(x, y) { 419 | if (x < 0 || y < 0 || x >= map_types[0].length || y >= map_types.length) { 420 | return false; 421 | } 422 | return true; 423 | } 424 | 425 | function isCollision(x, y) { 426 | if (!isInBounds(x, y)) return true; 427 | return map_heights[Math.floor(y)][Math.floor(x)] > cam_height + MAX_STEP_SIZE + 0.1; 428 | } 429 | 430 | function applyPlayerPhysics() { 431 | 432 | var new_x = cam_x + cam_vel_x * 2; 433 | var new_y = cam_y + cam_vel_y * 2; 434 | 435 | if (isCollision(new_x, new_y)) { 436 | cam_vel_x *= -0.5; 437 | cam_vel_y *= -0.5; 438 | } else if (isCollision(cam_x, new_y)) { 439 | cam_vel_y *= -0.5; 440 | } else if (isCollision(new_x, cam_y)) { 441 | cam_vel_x *= -0.5; 442 | } 443 | 444 | // update position 445 | cam_x += cam_vel_x; 446 | cam_y += cam_vel_y; 447 | 448 | 449 | 450 | // apply air pressure so we slow down if we let up on the keys 451 | cam_vel_x *= 0.9 452 | cam_vel_y *= 0.9; 453 | 454 | // dame for rotation, apply and slow down 455 | cam_angle += cam_vel_a; 456 | cam_vel_a *= 0.7; 457 | 458 | // if we're in the air or jumping apply the vertical velocity. 459 | var h = cam_height - map_heights[Math.floor(cam_y)][Math.floor(cam_x)]; 460 | 461 | if (cam_vel_vert > 0 || h > 0.01) { 462 | cam_height += cam_vel_vert; 463 | cam_vel_vert -= 0.2; 464 | } else { 465 | if (h < 0.1) { 466 | cam_vel_vert = -h / 2; 467 | } else { 468 | cam_vel_vert = 0; 469 | } 470 | } 471 | 472 | color = block_types[map_types[Math.floor(cam_y)][Math.floor(cam_x)]]; 473 | if (h < 0.01) { 474 | if (color.poision) { 475 | game.advanceState(); 476 | } 477 | if (color.bounce > 0) { 478 | cam_vel_vert = color.bounce; 479 | } 480 | } 481 | } 482 | 483 | // render doom-style game over screen! 484 | var update_game_over_calls = 0; 485 | function updateScreenGameOver() { 486 | update_game_over_calls += 1; 487 | 488 | game.draw_fps = false; 489 | 490 | if (update_game_over_calls < 30*2) { 491 | game.drawRect(0, 0, game.width, 30, "red"); 492 | 493 | for (var i = 0; i < 100; i++) { 494 | var x = game.randInt(-100, game.width - 100); 495 | var y = game.randInt(0, game.height - 20); 496 | 497 | var imgData=game.ctx.getImageData(x, y, 200, 40); 498 | game.ctx.putImageData(imgData, x, y + 15); 499 | } 500 | } else { 501 | game.drawText("Game Over!", {size:80, color:"white", x:game.width/2, y: game.height / 2 + 50, align:'center'}); 502 | game.drawText("Game Over!", {size:80, color:"black", x:game.width/2 + 1, y: game.height / 2 + 50 + 2, align:'center'}); 503 | } 504 | } 505 | 506 | 507 | // convert a depth-map on the scree into a stereogram 508 | function convertToStereogram() { 509 | var constraints = Array(game.width); 510 | var imageData = game.ctx.getImageData(0, 0, game.width, game.height); 511 | for (var y = 0; y < game.height; y++) { 512 | for (var x = 0; x < game.width; x++) { 513 | constraints[x] = x; 514 | } 515 | 516 | for (var x = 0; x < game.width; x++) { 517 | // index in the image data 518 | var i = (y * game.width + x) * 4; 519 | 520 | // z coordinate at point x 521 | var z = (imageData.data[i] + imageData.data[i+1] + imageData.data[i+2]) / (255*3); 522 | 523 | // stereographic separation at point x 524 | var s = stereoSeperation(z); 525 | 526 | // add constraints 527 | var p2 = x + Math.floor(s/2); 528 | 529 | var p1 = p2 - s; 530 | 531 | if (p1 >= 0 && p2 < game.width) { 532 | constraints[p2] = p1; 533 | } 534 | } 535 | 536 | for (var x = 0; x < game.width; x++) { 537 | // index in the image data 538 | var write_index = (y * game.width + x) * 4; 539 | if (constraints[x] == x) { 540 | var c = Math.random() > 0.5 ? 255 : 0; 541 | imageData.data[write_index] = imageData.data[write_index+1] = imageData.data[write_index+2] = c; 542 | } else { 543 | if (render_mode == 2) { 544 | // deal with floating point constraint by taking nearest neighbor 545 | var source_index = (y * game.width + Math.floor(constraints[x])) * 4; 546 | imageData.data[write_index] = imageData.data[write_index+1] = imageData.data[write_index+2] = imageData.data[source_index]; 547 | 548 | } else { 549 | // deal with floating point constraint by blending 550 | 551 | var source_index1 = (y * game.width + Math.floor(constraints[x])) * 4; 552 | var source_index2 = (y * game.width + Math.ceil(constraints[x])) * 4; 553 | 554 | // our position between the two pixels 555 | var f = constraints[x] - Math.floor(constraints[x]); 556 | var c = imageData.data[source_index1] * (1.0 - f) + imageData.data[source_index2] * f; 557 | imageData.data[write_index] = imageData.data[write_index+1] = imageData.data[write_index+2] = c; 558 | } 559 | } 560 | } 561 | } 562 | 563 | game.ctx.putImageData(imageData, 0, 0); 564 | } 565 | 566 | function stereoSeperation(z) { 567 | return ((1 - DEPTH_OF_FIELD*z) / (2 - DEPTH_OF_FIELD*z)) * EYE_SEPARATION; 568 | } 569 | 570 | -------------------------------------------------------------------------------- /simple_raycaster.js: -------------------------------------------------------------------------------- 1 | // You can ignore this code for now. 2 | var game = new ALGame(800, 600); 3 | game.addGameState(updateScreen); 4 | game.start(30); 5 | game.setBackgroundColor('black'); 6 | 7 | var map = [[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 8 | [1,0,0,0,0,0,0,0,0,0,0,0,0,0,1], 9 | [1,0,0,0,0,0,0,0,0,0,0,0,0,0,1], 10 | [1,0,0,3,4,3,4,3,4,0,0,0,0,0,1], 11 | [1,0,0,0,0,0,0,0,0,0,0,1,0,0,1], 12 | [1,0,0,0,0,0,0,0,0,0,0,1,0,0,1], 13 | [1,0,0,0,0,0,0,0,0,0,0,1,0,0,1], 14 | [1,0,0,0,0,0,0,2,0,0,0,0,0,0,1], 15 | [1,0,0,0,0,0,0,2,0,0,0,0,0,0,1], 16 | [1,0,0,0,0,0,0,2,0,0,0,0,0,0,1], 17 | [1,0,0,0,0,0,0,0,0,0,0,0,0,0,1], 18 | [1,0,1,1,1,1,1,0,0,0,0,0,0,0,1], 19 | [1,0,0,0,0,0,0,0,0,0,0,0,0,0,1], 20 | [1,0,0,0,0,0,0,0,0,0,0,0,0,0,1], 21 | [1,1,1,1,1,1,1,5,5,5,1,1,1,1,1]]; 22 | 23 | var colors = { 24 | 1: 'purple', 25 | 2: 'green', 26 | 3: 'yellow', 27 | 4: 'orange', 28 | 5: 'purple' 29 | }; 30 | 31 | var cam_x = 2.0; 32 | var cam_y = 2.0; 33 | var cam_angle = 0; 34 | 35 | var FIELD_OF_VIEW = 70; 36 | 37 | var INCREMENT = 0.05; 38 | 39 | var SCALE = 5000; 40 | 41 | var WALK_SPEED = 0.1; 42 | 43 | // convert degrees to radians 44 | function degreesToRadians(d) { 45 | return (d * Math.PI) / 180.0; 46 | } 47 | 48 | function castRay(x, y, angle) { 49 | var x_inc = Math.cos(degreesToRadians(angle)) * INCREMENT; 50 | var y_inc = Math.sin(degreesToRadians(angle)) * INCREMENT; 51 | 52 | var distance = 0; 53 | while(map[Math.floor(y)][Math.floor(x)] == 0) { 54 | x += x_inc; 55 | y += y_inc; 56 | distance += 1; 57 | } 58 | 59 | var color = colors[map[Math.floor(y)][Math.floor(x)]]; 60 | 61 | return { 62 | color: color, 63 | distance: distance 64 | } 65 | } 66 | 67 | function updateScreen() { 68 | game.clearScreen(); 69 | 70 | if (game.isKeyPressed('j')) { 71 | cam_angle -= 2; 72 | } 73 | 74 | if (game.isKeyPressed('l')) { 75 | cam_angle += 2; 76 | } 77 | 78 | if (game.isKeyPressed('i')) { 79 | cam_x += Math.cos(degreesToRadians(cam_angle)) * WALK_SPEED; 80 | cam_y += Math.sin(degreesToRadians(cam_angle)) * WALK_SPEED; 81 | } 82 | 83 | if (game.isKeyPressed('k')) { 84 | cam_x -= Math.cos(degreesToRadians(cam_angle)) * WALK_SPEED; 85 | cam_y -= Math.sin(degreesToRadians(cam_angle)) * WALK_SPEED; 86 | } 87 | 88 | 89 | var ray_angle = cam_angle - FIELD_OF_VIEW / 2.0; 90 | var angle_inc = FIELD_OF_VIEW / game.width; 91 | 92 | for (var column = 0; column < game.width; column++) { 93 | ray_angle = ray_angle + angle_inc; 94 | var r = castRay(cam_x, cam_y, ray_angle); 95 | var h = Math.min(SCALE / r.distance, game.height / 2); 96 | game.drawLine(column, game.height / 2 - h, column, game.height / 2 + h, r.color); 97 | } 98 | } 99 | 100 | --------------------------------------------------------------------------------