This commit is contained in:
Kim Ravn Hansen
2025-09-28 15:03:11 +02:00
parent 95068939af
commit 2053dd3113
12 changed files with 557 additions and 669 deletions

View File

@@ -1,20 +1,65 @@
import { TileMap } from "./ascii_tile_map.js";
import { NRGBA } from "./ascii_textureloader.js";
import { TileMap, Tile } from "./ascii_tile_map.js";
import { AsciiWindow } from "./ascii_window.js";
/**
* Which side of a tile did the ray strike
*/
export const Side = {
X_AXIS: 0,
Y_AXIS: 1,
};
class RayCollision {
mapX = 0;
mapY = 0;
rayLength = 0;
side = Side.X_AXIS;
/** @type {Tile} */
tile;
}
class RayCastResult {
hitWall = false;
hitSprite = false;
wallCollision = new RayCollision();
/** @type {RayCollision[]} */
collisions = [];
}
/**
* @typedef {object} FirstPersonRendererOptions
* @property {string} wallChar
* @property {NRGBA} floorColor
* @property {string} floorChar
* @property {NRGBA} ceilingColor
* @property {string} ceilingChar
* @property {number} viewDistance
* @property {number} fov
*/
/**
* @type {FirstPersonRendererOptions}
*/
export const DefaultRendererOptions = {
wallChar: "W",
floorColor: new NRGBA(0.365, 0.165, 0.065),
floorChar: "f",
ceilingColor: new NRGBA(0.3, 0.3, 0.3),
ceilingChar: "c",
fadeOutColor: new NRGBA(0.3, 0.3, 0.3),
viewDistance: 5,
fov: Math.PI / 3, // 60 degrees - good for spooky
};
export class FirstPersonRenderer {
/**
* @param {AsciiWindow} aWindow the window we render onto.
* @param {TileMap} map
* @param {number} fov field of view (in radians)
* @param {number} maxDist maximum view distance.
* @param {TexturePack} textures
* @param {Texture[]} textures
* @param {FirstPersonRendererOptions} options
*/
constructor(aWindow, map, fov, maxDist, textures) {
constructor(aWindow, map, textures, options) {
/** @constant @readonly @type {TileMap} */
this.map = map;
@@ -22,91 +67,161 @@ export class FirstPersonRenderer {
this.window = aWindow;
/** @constant @readonly @type {number} */
this.fov = fov;
this.fov = options.fov ?? DefaultRendererOptions.fov;
/** @constant @readonly @type {number} */
this.maxDist = maxDist;
this.viewDistance = options.viewDistance ?? DefaultRendererOptions.viewDistance;
/** @constant @readonly @type {Texture[]} */
this.textures = textures;
/** @constant @readonly @type {string} */
this.wallChar = options.wallChar ?? DefaultRendererOptions.wallChar;
/** @constant @readonly @type {NRGBA} */
this.floorColor = options.floorColor ?? DefaultRendererOptions.floorColor;
/** @constant @readonly @type {string} */
this.floorChar = options.floorChar ?? DefaultRendererOptions.floorChar;
/** @constant @readonly @type {NRGBA} */
this.ceilingColor = options.ceilingColor ?? DefaultRendererOptions.ceilingColor;
/** @constant @readonly @type {string} */
this.ceilingChar = options.ceilingChar ?? DefaultRendererOptions.ceilingChar;
/**
* Pre-computed colors to use when drawing floors, ceilings and "fadeout"
*
* There is one entry for every screen row.
* Each entry contains a color to use when drawing floors, ceilings, and "fadeout".
*
* @constant @readonly @type {Array<Array<string>>}
*/
this.shades = [];
/**
* Pre-compute the shades variable
*/
this.computeShades();
}
computeShades() {
const screenHeight = this.window.height;
const halfScreenHeight = screenHeight / 2;
const lineHeight = Math.floor(screenHeight / this.viewDistance);
const minY = Math.floor(-lineHeight / 2 + halfScreenHeight); // if y lower than minY, then we're painting ceiling
const maxY = Math.floor(lineHeight / 2 + halfScreenHeight); // if y higher than maxY then we're painting floor
for (let y = 0; y < screenHeight; y++) {
if (y < minY) {
//
// y is smaller than minY. This means we're painting above
// the walls, i.e. painting the ceiling.
// The closer y is to minY, the farther away this part of the
// ceiling is.
//
// High diff => near
// Low diff => far
//
const diff = minY - y;
this.shades.push([this.ceilingChar, this.ceilingColor.mulledRGB(diff / minY).toCSS()]);
} else if (y >= maxY) {
//
// Floor
//
const diff = y - maxY;
this.shades.push([this.floorChar, this.floorColor.mulledRGB(diff / minY).toCSS()]);
} else {
//
// The darkness at the end of the tunnel
//
this.shades.push([" ", "#000"]);
}
}
}
renderFrame(posX, posY, dirAngle, commit = true) {
const benchmarkStart = performance.now();
const screenWidth = this.window.width;
/** @type {Map<number,Tile} The coordinates of all the tiles checked while rendering this frame*/
const coordsCheckedFrame = new Map();
for (let x = 0; x < screenWidth; x++) {
/** @type {Map<number,Tile} The coordinates of all the tiles checked while casting this single ray*/
const coordsCheckedRay = new Map();
const angleOffset = (x / screenWidth - 0.5) * this.fov; // in radians
const rayAngle = dirAngle + angleOffset;
const rayDirX = Math.cos(rayAngle);
const rayDirY = Math.sin(rayAngle);
// Cast ray using our DDA function
const hit = this.castRay(posX, posY, rayDirX, rayDirY, rayAngle);
const ray = this.castRay(posX, posY, rayDirX, rayDirY, coordsCheckedRay);
coordsCheckedRay.forEach((tile, idx) => {
coordsCheckedFrame.set(idx, tile);
});
//
// Did we hit something?
//
if (!hit) {
// we did not hit anything. Either the ray went out of bounds,
// or it went too far, so move on to next pseudo-pixel
this.renderNoHitCol(x);
continue;
}
// Render a single screen column
this.renderColumn(x, ray, rayDirX, rayDirY, angleOffset);
}
//
// Our ray hit a wall, render it.
this.renderHitCol(x, hit, rayDirX, rayDirY, angleOffset);
const renderTime = performance.now() - benchmarkStart;
// Did it take more than 5ms to render the scene?
if (renderTime > 5) {
console.log("Rendering took a long time", { renderTime });
}
if (commit) {
this.window.commitToDOM();
}
}
/**
* Render a vertical column of pixels on the screen at the x coordinate.
* This occurs when the ray did not hit anything.
*
* @protected
*/
renderNoHitCol(x) {
const screenHeight = this.window.height;
const halfScreenHieght = screenHeight / 2;
const lineHeight = Math.floor(screenHeight / this.maxDist);
let minY = Math.floor(-lineHeight / 2 + halfScreenHieght);
let maxY = Math.floor(lineHeight / 2 + halfScreenHieght);
for (let y = 0; y < screenHeight; y++) {
if (y < minY) {
this.window.put(x, y, "c", "#333"); // ceiling
} else if (y > maxY) {
this.window.put(x, y, "f", "#b52"); // floor
} else {
const char = ["·", "÷", "'", "~"][(y + x) % 4];
this.window.put(x, y, char, "#222"); // the far distance
}
requestAnimationFrame(() => {
const benchmarkStart = performance.now();
this.window.commitToDOM();
const commitTime = performance.now() - benchmarkStart;
if (commitTime > 5) {
console.log("Updating DOM took a long time:", { commitTime });
}
});
}
}
/**
* Render a column on the screen where the ray hit a wall.
* @param {number} x
* @param {RayCastResult} ray
* @param {number} rayDirX
* @param {number} rayDirY
* @param {number} angleOffset for far (in radians) is this column from the middle of the screen
*
* @protected
*/
renderHitCol(x, hit, rayDirX, rayDirY, angleOffset) {
const { rayLength, side, textureOffsetX, mapX, mapY } = hit;
renderColumn(x, ray, rayDirX, rayDirY, angleOffset) {
//
// Check if we hit anything at all
if (ray.collisions.length === 0) {
//
// We didn't hit anything. Just paint floor, wall, and darkness
for (let y = 0; y < this.window.height; y++) {
const [char, color] = this.shades[y];
this.window.put(x, y, char, color);
}
return;
}
const tile = this.map.get(mapX, mapY);
const safeDistance = Math.max(rayLength * Math.cos(angleOffset), 1e-9); // Avoid divide by zero
const { rayLength, side, sampleU, tile: wallTile } = ray.collisions[0];
const distance = Math.max(rayLength * Math.cos(angleOffset), 1e-12); // Avoid divide by zero
//
// Calculate perspective.
//
const screenHeight = this.window.height;
const halfScreenHieght = screenHeight / 2;
const lineHeight = Math.floor(screenHeight / safeDistance);
let minY = Math.floor(-lineHeight / 2 + halfScreenHieght);
let maxY = Math.floor(lineHeight / 2 + halfScreenHieght);
let unsafeMinY = minY; // can be lower than zero
const lineHeight = Math.round(screenHeight / distance); // using round() because floor() gives aberrations when distance == (n + 0.500)
const halfScreenHeight = screenHeight / 2;
const halfLineHeight = lineHeight / 2;
let minY = Math.floor(halfScreenHeight - halfLineHeight);
let maxY = Math.floor(halfScreenHeight + halfLineHeight);
let unsafeMinY = minY; // can be lower than zero - it happens when we get so close to a wall we cannot see top or bottom
if (minY < 0) {
minY = 0;
@@ -115,30 +230,26 @@ export class FirstPersonRenderer {
maxY = screenHeight - 1;
}
//
// Pick texture (here grid value decides which texture)
const texture = this.textures[tile.textureId % this.textures.length];
// X coord on texture
let sampleU = textureOffsetX;
if (side === 0 && rayDirX > 0) {
sampleU = 1 - sampleU;
}
if (side === 1 && rayDirY < 0) {
sampleU = 1 - sampleU;
}
//
const wallTexture = this.textures[wallTile.textureId];
for (let y = 0; y < screenHeight; y++) {
//
// Are we hitting the ceiling?
//
if (y < minY) {
this.window.put(x, y, "c", "#333");
if (y < minY || y > maxY) {
const [char, color] = this.shades[y];
this.window.put(x, y, char, color);
continue;
}
if (y > maxY) {
this.window.put(x, y, "f", "#b52");
if (y === minY) {
this.window.put(x, y, "m", "#0F0");
continue;
}
if (y === maxY) {
this.window.put(x, y, "M", "#F00");
continue;
}
@@ -146,44 +257,39 @@ export class FirstPersonRenderer {
// Map screen y to texture y
let sampleV = (y - unsafeMinY) / lineHeight; // y- coordinate of the texture point to sample
const color = texture.sample(sampleU, sampleV);
const color = wallTexture.sample(sampleU, sampleV);
//
// North-south walls are shaded differently from east-west walls
let shade = side === Side.X_AXIS ? 0.7 : 1.0; // MAGIC NUMBERS
let shade = side === Side.X_AXIS ? 0.8 : 1.0; // MAGIC NUMBERS
//
// Dim walls that are far away
shade = shade / (1 + rayLength * 0.1);
const lightLevel = 1 - rayLength / this.viewDistance;
//
// Darken the image
color.mulRGB(shade);
color.mulRGB(shade * lightLevel);
// const distancePalette = ["█", "▓", "▒", "░", " "];
const distancePalette = ["#", "#", "#", "%", "+", "÷", " ", " "];
const char = distancePalette[rayLength | 0];
this.window.put(x, y, char, color.toCSS());
this.window.put(x, y, this.wallChar, color.toCSS());
}
}
/**
* @param {number} camX x-coordinate of the camera (is the same
* @param {number} camY y-coordinate of the camera
* @parma {number} dirX x-coordinate of the normalized vector of the viewing direction of the camera.
* @parma {number} dirX y-coordinate of the normalized vector of the viewing direction of the camera.
* @param {number} dirX x-coordinate of the normalized vector of the viewing direction of the camera.
* @param {number} dirX y-coordinate of the normalized vector of the viewing direction of the camera.
* @param {Set<number>} coodsChecked
*
* @returns {RayCastResult}
*
*/
castRay(camX, camY, dirX, dirY) {
castRay(camX, camY, dirX, dirY, coordsChecked) {
// Current map square
let mapX = Math.floor(camX);
let mapY = Math.floor(camY);
if (dirX === 0 || dirY === 0) {
console.log("Divide by zero is incoming", { dirX, dirY });
}
Number.MAX_SAFE_INTEGER;
// Length of ray from one x or y-side to next x or y-side
const deltaDistX = dirX === 0 ? 1e15 : Math.abs(1 / dirX);
const deltaDistY = dirY === 0 ? 1e15 : Math.abs(1 / dirY);
@@ -193,6 +299,7 @@ export class FirstPersonRenderer {
let stepY; // When DDA takes a vertical step (on the map), how far should it move?
let sideDistX; // How far has the ray moved horizontally (on the map) ?
let sideDistY; // How far has the ray moved vertically (on the map) ?
let side = Side.X_AXIS;
//
// Calculate how to move along the x-axis
@@ -214,30 +321,32 @@ export class FirstPersonRenderer {
sideDistY = (mapY + 1.0 - camY) * deltaDistY; // we've moved from the camera to the top edge of the tile
}
//
// Did the ray hit a wall ?
//
let hit = false;
//
// Did the ray hit a wall on a horizontal edge or a vertical edge?
//
let side = Side.X_AXIS;
/**
* Any sprites the ray has hit on its way.
* They are ordered in reverse order of closeness to the camera,
* so that if they are drawn in their array ordering, they will
* appear in the correct order on the screen.
*
* @type {RayCastResult}
*/
const result = new RayCastResult();
// DDA loop
while (!hit) {
while (!result.hitWall) {
//
// Check if ray is longer than maxDist
if (Math.min(sideDistX, sideDistY) > this.maxDist) {
return false; // ray got too long, no hit, exit early
// Check if ray is longer than viewDistance
if (Math.min(sideDistX, sideDistY) > this.viewDistance) {
return result;
}
//
// Check for out of bounds
if (mapX < 0 || mapX >= this.map.width || mapY < 0 || mapY >= this.map.height) {
return false; // ray got outside the map, no hit, exit early
return result;
}
let wallDist, sampleU;
//
// Should we step in the x- or y-direction
// DDA dictates we always move along the shortest vector
@@ -248,6 +357,13 @@ export class FirstPersonRenderer {
sideDistX += deltaDistX;
mapX += stepX;
side = Side.X_AXIS;
// Ray hit the east or west edge of the wall-tile
wallDist = (mapX - camX + (1 - stepX) / 2) / dirX;
sampleU = (camY + wallDist * dirY) % 1;
if (dirX > 0) {
sampleU = 1 - sampleU;
}
} else {
//
// Move vertically
@@ -255,72 +371,73 @@ export class FirstPersonRenderer {
sideDistY += deltaDistY;
mapY += stepY;
side = Side.Y_AXIS;
// Ray hit the north or south edge of the wall-tile
wallDist = (mapY - camY + (1 - stepY) / 2) / dirY;
sampleU = (camX + wallDist * dirX) % 1;
if (dirY < 0) {
sampleU = 1 - sampleU;
}
}
const tile = this.map.get(mapX, mapY);
coordsChecked.set(this.map.tileIdx(mapX, mapY), tile);
const rayLength = Math.hypot(
wallDist * dirX, //
wallDist * dirY, //
);
//
// --------------------------
// Add a Sprite to the result
// --------------------------
if (tile.sprite || tile.wall) {
//
// Prepend the element to the array so rear-most sprites
// appear first in the array,
// enabling us to simply draw from back to front
const collision = new RayCollision();
result.collisions.unshift(collision);
collision.mapX = mapX;
collision.mapY = mapY;
collision.rayLength = rayLength;
collision.tile = tile;
collision.sampleU = sampleU;
collision.side = side;
if (result.sprite) {
collision.sprite = true;
}
if (result.wall) {
collision.wall = true;
return;
}
}
//
// Check if ray hit a wall
if (this.map.isWall(mapX, mapY)) {
//
// Ray hit a wall, proceed to the rest of the algorithm.
//
hit = true;
// --------------------------
// Add a Wall to the result
// (and return)
// --------------------------
if (tile.wall) {
result.hitWall = true;
// <todo>
// DELETE BELOW
result.wallCollision.tile = tile;
result.wallCollision.side = side;
result.wallCollision.mapX = mapX;
result.wallCollision.mapY = mapY;
result.wallCollision.rayLength = rayLength;
result.wallCollision.sampleU = sampleU;
// </todo>
return result;
}
}
//
// The distance to the wall, measured perpendicularly to the viewing angle
// The perpendicular distance is used to avoid the fish-eye distortion
// that would occur if we measured the Euclidean distance from the camera
// to the where the ray impacted the wall. This makes sense when you realize
// that, when looking directly at a wall, the shortest rays would be right in
// front of the camera, making it seem as if the wall bowed outwards toward
// the camera.
//
let perpWallDist;
//
// Where did we hit the wall. Measured as a normalized x-coordinate only;
//
let textureOffsetX;
//
// Determine both the perpendicular distance to the wall
// and the x-coordinate (on the wall) where the ray hit it.
//
if (side === Side.X_AXIS) {
//
// Ray hit the left or right edge of the wall-tile
//
perpWallDist = (mapX - camX + (1 - stepX) / 2) / dirX;
textureOffsetX = camY + perpWallDist * dirY;
} else {
//
// Ray hit the upper or lower edge of the wall-tile
//
perpWallDist = (mapY - camY + (1 - stepY) / 2) / dirY;
textureOffsetX = camX + perpWallDist * dirX;
}
//
// Normalize textureOffsetX. We only want the fractional part.
//
textureOffsetX -= Math.floor(textureOffsetX);
const rayLength = Math.hypot(
perpWallDist * dirX, //
perpWallDist * dirY, //
);
return {
mapX,
mapY,
side,
rayLength,
textureOffsetX,
};
}
}
if (Math.PI < 0 && AsciiWindow && TileMap) {
if (Math.PI < 0 && AsciiWindow && TileMap && Tile) {
("STFU Linda");
}