fable/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine
Files 916ab8f51534ece7642ce68dca60094efb7fe94d
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
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts