benjivers/val Public Public: anyone can read this; the crew can change it.
Files look-around-always
| 1 | /** |
| 2 | * The ray maths. Pure numbers in, pure numbers out: no canvas, no DOM, no |
| 3 | * state — so every decision the first-person view makes can be tested |
| 4 | * without a browser. |
| 5 | * |
| 6 | * The camera is a flat projection plane at `projection()` pixels from the |
| 7 | * eye. A column maps to an angle through that plane, not by sweeping the |
| 8 | * field of view evenly, which is what keeps straight walls straight. Depth |
| 9 | * is always the perpendicular distance to the plane: the fish-eye correction |
| 10 | * and the sprite z-clip both depend on the two being the same measure. |
| 11 | * |
| 12 | * Nothing here allocates. The DDA writes its answer into a result object the |
| 13 | * caller owns, so a 900-column frame allocates nothing at all. |
| 14 | */ |
| 15 | |
| 16 | export const FOV = (75 * Math.PI) / 180; |
| 17 | |
| 18 | /** Pixels from the eye to the projection plane for a screen this wide. */ |
| 19 | export const projection = (width, fov = FOV) => width / 2 / Math.tan(fov / 2); |
| 20 | |
| 21 | /** The angle of the ray through screen column `col`, relative to the view. */ |
| 22 | export function columnOffset(col, width, fov = FOV) { |
| 23 | const cameraX = (2 * (col + 0.5)) / width - 1; |
| 24 | return Math.atan(cameraX * Math.tan(fov / 2)); |
| 25 | } |
| 26 | |
| 27 | /** The world angle of the ray through screen column `col`. */ |
| 28 | export const columnAngle = (col, width, view, fov = FOV) => view + columnOffset(col, width, fov); |
| 29 | |
| 30 | /** The screen column a world angle falls on, fractional, off-screen allowed. */ |
| 31 | export function angleColumn(offset, width, fov = FOV) { |
| 32 | return (width / 2) * (1 + Math.tan(offset) / Math.tan(fov / 2)); |
| 33 | } |
| 34 | |
| 35 | /** A wall `tall` units high at this depth covers this many pixels. */ |
| 36 | export const sliceHeight = (perp, proj, tall) => (tall * proj) / Math.max(perp, 1e-6); |
| 37 | |
| 38 | /** A fresh result object for `castRay` to fill. One per renderer, not per ray. */ |
| 39 | export const hitResult = () => ({ dist: 0, side: 0, col: 0, row: 0, wall: false }); |
| 40 | |
| 41 | /** |
| 42 | * March a ray through the tile grid until it meets a solid tile. |
| 43 | * |
| 44 | * `solid(col, row)` answers for the grid; `out` is filled with the distance |
| 45 | * travelled, which side of the tile was struck (0 = an east or west face, |
| 46 | * 1 = a north or south face) and the tile itself. A ray that runs out of |
| 47 | * range reports `wall: false` at `max`. |
| 48 | */ |
| 49 | export function castRay(solid, x, y, angle, tile, max, out) { |
| 50 | const dx = Math.cos(angle); |
| 51 | const dy = Math.sin(angle); |
| 52 | let col = Math.floor(x / tile); |
| 53 | let row = Math.floor(y / tile); |
| 54 | const deltaX = dx === 0 ? Infinity : Math.abs(tile / dx); |
| 55 | const deltaY = dy === 0 ? Infinity : Math.abs(tile / dy); |
| 56 | const stepX = dx < 0 ? -1 : 1; |
| 57 | const stepY = dy < 0 ? -1 : 1; |
| 58 | let sideX = dx === 0 ? Infinity : dx < 0 ? (x - col * tile) / -dx : ((col + 1) * tile - x) / dx; |
| 59 | let sideY = dy === 0 ? Infinity : dy < 0 ? (y - row * tile) / -dy : ((row + 1) * tile - y) / dy; |
| 60 | |
| 61 | if (solid(col, row)) { |
| 62 | out.dist = 0; |
| 63 | out.side = 0; |
| 64 | out.col = col; |
| 65 | out.row = row; |
| 66 | out.wall = true; |
| 67 | return out; |
| 68 | } |
| 69 | for (;;) { |
| 70 | let dist; |
| 71 | let side; |
| 72 | if (sideX < sideY) { |
| 73 | dist = sideX; |
| 74 | col += stepX; |
| 75 | sideX += deltaX; |
| 76 | side = 0; |
| 77 | } else { |
| 78 | dist = sideY; |
| 79 | row += stepY; |
| 80 | sideY += deltaY; |
| 81 | side = 1; |
| 82 | } |
| 83 | if (dist > max) { |
| 84 | out.dist = max; |
| 85 | out.side = side; |
| 86 | out.col = col; |
| 87 | out.row = row; |
| 88 | out.wall = false; |
| 89 | return out; |
| 90 | } |
| 91 | if (solid(col, row)) { |
| 92 | out.dist = dist; |
| 93 | out.side = side; |
| 94 | out.col = col; |
| 95 | out.row = row; |
| 96 | out.wall = true; |
| 97 | return out; |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * How lit a wall slice is: one over the distance, floored so a far wall is |
| 104 | * dim rather than black, and a north–south face darker than an east–west one |
| 105 | * so a corner reads as a corner. |
| 106 | */ |
| 107 | export function wallLight(perp, side, tile) { |
| 108 | const far = 1 / (1 + (perp / (tile * 7)) ** 2); |
| 109 | const face = side === 1 ? 0.62 : 1; |
| 110 | return Math.max(0.12, Math.min(1, 0.25 + 0.75 * far)) * face; |
| 111 | } |
| 112 | |
| 113 | /** A floor or ceiling band dims the same way, by how far down the screen it is. */ |
| 114 | export const bandLight = (rowFromHorizon, halfHeight) => |
| 115 | Math.max(0.1, Math.min(1, rowFromHorizon / Math.max(1, halfHeight))); |
| 116 | |
| 117 | /** |
| 118 | * Where a thing in the world lands on the screen. |
| 119 | * |
| 120 | * Returns the fractional column of its centre, its perpendicular depth and |
| 121 | * the pixel height of something `tall` units high there. `behind` is true |
| 122 | * when it is at or past the camera plane and must not be drawn at all. |
| 123 | */ |
| 124 | export function project(px, py, view, sx, sy, width, proj, tall, out, fov = FOV) { |
| 125 | const dx = sx - px; |
| 126 | const dy = sy - py; |
| 127 | const dist = Math.hypot(dx, dy); |
| 128 | let offset = Math.atan2(dy, dx) - view; |
| 129 | while (offset > Math.PI) offset -= Math.PI * 2; |
| 130 | while (offset < -Math.PI) offset += Math.PI * 2; |
| 131 | const perp = dist * Math.cos(offset); |
| 132 | out.dist = dist; |
| 133 | out.offset = offset; |
| 134 | out.perp = perp; |
| 135 | out.behind = perp <= 1 || Math.abs(offset) > fov / 2 + 0.8; |
| 136 | out.column = out.behind ? -1 : angleColumn(offset, width, fov); |
| 137 | out.size = out.behind ? 0 : sliceHeight(perp, proj, tall); |
| 138 | return out; |
| 139 | } |
| 140 | |
| 141 | export const projectResult = () => ({ dist: 0, offset: 0, perp: 0, behind: true, column: -1, size: 0 }); |
| 142 | |
| 143 | /** |
| 144 | * Is a sprite column in front of the wall drawn there? A sprite slightly |
| 145 | * nearer than the wall still counts as hidden by a hair of slack, so a body |
| 146 | * flush against a corner does not bleed through it. |
| 147 | */ |
| 148 | export const spriteColumnVisible = (spritePerp, wallPerp) => spritePerp < wallPerp - 2; |
| 149 | |
| 150 | /** |
| 151 | * How much smoke a ray crosses between the eye and `dist`. |
| 152 | * |
| 153 | * The chord of the circle that falls inside the segment, as a fraction of the |
| 154 | * circle's width: a ray that clips the edge of a smoke barely fogs, one that |
| 155 | * goes through the middle fogs completely. That is what makes a smoke read as |
| 156 | * a volume you are standing in rather than a disc painted on the floor. |
| 157 | */ |
| 158 | export function smokeAlong(x, y, dx, dy, cx, cy, r, dist) { |
| 159 | const ox = x - cx; |
| 160 | const oy = y - cy; |
| 161 | const b = ox * dx + oy * dy; |
| 162 | const c = ox * ox + oy * oy - r * r; |
| 163 | const disc = b * b - c; |
| 164 | if (disc <= 0) return 0; |
| 165 | const root = Math.sqrt(disc); |
| 166 | const enter = Math.max(0, -b - root); |
| 167 | const leave = Math.min(dist, -b + root); |
| 168 | if (leave <= enter) return 0; |
| 169 | return Math.min(1, (leave - enter) / (r * 1.1)); |
| 170 | } |