I wrote a raycaster in Javascript following Lodev’s tutorials. (https://lodev.org/cgtutor/raycasting.html)
I think I understand the math and everything, and the raycaster works pretty great on normal/narrow FOV angles, but on large FOV angles the object go on a sort of elliptical path around the raycaster origin instead of rotating in a circle when rotating left/right. Now I’m pretty sure this is not expected and it looks whacky. See video (excuse the whacky colors I had to get it down to an appropriate size for stackoverflow):

You can clearly see that the wall goes in an elliptical curve around the player. On the image, I’m using direction vector length 1 and plane vector length 2.2 (Giving us a large FOV)
I cannot find any faults in the algorithm and the math used should most likely not produce this sort of result. For each ray, we should be going the right direction by adding the direction and plane vectors scaled by the X coordinate of the camera. Then taking the perpendicular distances to the camera plane.
Any ideas?
Here’s the code, I’ve really made only amendments for Javascript and haven’t modified the algorithm from the original tutorial.
let ZBuffer: { [key: number]: number } = {};
let width = Math.ceil(this.spacing);
this.ctx.save();
for (let column = 0; column < this.resolution; column++) {
// x-coordinate in camera space scaled from -1 to 1
let cameraX = (2 * column) / this.resolution - 1;
// get the ray direction vector
let rayDirX = player.position.dirX + player.position.planeX * cameraX;
let rayDirY = player.position.dirY + player.position.planeY * cameraX;
// which box of the map we're in
let mapX = Math.floor(player.position.x);
let mapY = Math.floor(player.position.y);
// length of ray from current position to next x or y-side
let sideDistX: number;
let sideDistY: number;
//length of ray from one x or y-side to next x or y-side
//these are derived as:
//deltaDistX = sqrt(1 + (rayDirY * rayDirY) / (rayDirX * rayDirX))
//deltaDistY = sqrt(1 + (rayDirX * rayDirX) / (rayDirY * rayDirY))
//which can be simplified to abs(|rayDir| / rayDirX) and abs(|rayDir| / rayDirY)
//where |rayDir| is the length of the vector (rayDirX, rayDirY). Its length,
//unlike (dirX, dirY) is not 1, however this does not matter, only the
//ratio between deltaDistX and deltaDistY matters, due to the way the DDA
//stepping further below works. So the values can be computed as below.
// Division through zero is prevented
let deltaDistX = Math.abs(1 / rayDirX);
let deltaDistY = Math.abs(1 / rayDirY);
// perpendicular wall distance
let perpWallDist: number;
// what direction to step in x or y-direction (either +1 or -1)
let stepX: number;
let stepY: number;
let hit: number = 0; // was there a wall hit?
let side: number; // was a NS or a EW wall hit? if x then side = 0, if y then side = 1
// calculate step and initial sideDist
if (rayDirX < 0) {
stepX = -1;
sideDistX = (player.position.x - mapX) * deltaDistX;
} else {
stepX = 1;
sideDistX = (mapX + 1.0 - player.position.x) * deltaDistX;
}
if (rayDirY < 0) {
stepY = -1;
sideDistY = (player.position.y - mapY) * deltaDistY;
} else {
stepY = 1;
sideDistY = (mapY + 1.0 - player.position.y) * deltaDistY;
}
// perform DDA
let range = this.range;
while (hit == 0 && range >= 0) {
// jump to next map square, either in x-direction, or in y-direction
if (sideDistX < sideDistY) {
sideDistX += deltaDistX;
mapX += stepX;
side = 0;
} else {
sideDistY += deltaDistY;
mapY += stepY;
side = 1;
}
// Check if ray has hit a wall
if (map.get(mapX, mapY) == 1) hit = 1;
range -= 1;
}
// Calculate distance projected on camera direction. This is the shortest distance from the point where the wall is
// hit to the camera plane. Euclidean to center camera plane would give fisheye effect!
// This can be computed as (mapX - posX + (1 - stepX) / 2) / rayDirX for side == 0, or same formula with Y
// for size == 1, but can be simplified to the code below thanks to how sideDist and deltaDist are computed:
// because they were left scaled to |rayDir|. sideDist is the entire length of the ray above after the multiple
// steps, but we subtract deltaDist once because one step more into the wall was taken above.
if (side == 0) perpWallDist = sideDistX - deltaDistX;
else perpWallDist = sideDistY - deltaDistY;
// SET THE ZBUFFER FOR THE SPRITE CASTING
ZBuffer[column] = perpWallDist; //perpendicular distance is used
// Calculate height of line to draw on screen
let lineHeight: number = this.height / perpWallDist;
// calculate lowest and highest pixel to fill in current stripe
let drawStartY = -lineHeight / 2 + this.height / 2;
let drawEndY = lineHeight / 2 + this.height / 2;
let texture = map.wallTexture;
// calculate value of wallX
let wallX: number; // where exactly the wall was hit
if (side == 0) wallX = player.position.y + perpWallDist * rayDirY;
else wallX = player.position.x + perpWallDist * rayDirX;
wallX -= Math.floor(wallX);
// x coordinate on the texture
let texX = Math.floor(wallX * texture.width);
if (side == 0 && rayDirX > 0) texX = texture.width - texX - 1;
if (side == 1 && rayDirY < 0) texX = texture.width - texX - 1;
this.ctx.globalAlpha = 1;
if (hit) {
let left = Math.floor(column * this.spacing);
let wallHeight = drawEndY - drawStartY;
this.ctx.drawImage(
texture.image,
texX, // sx
0, // sy
1, // sw
texture.height, // sh
left, // dx
drawStartY, // dy - yes we go into minus here, it'll be ignored anyway
width, // dw
wallHeight // dh
);
// this is the shading of the texture - a sort of black overlay
this.ctx.fillStyle = `#000000`;
let alpha =
(perpWallDist +
// step.shading
0) /
this.lightRange -
map.light;
alpha = Math.min(alpha, 0.8);
if (side == 1) {
// give x and y sides different brightness
alpha = alpha * 2;
}
alpha = Math.min(alpha, 0.85);
// ensure walls are always at least a little bit visible - alpha 1 is all black
this.ctx.globalAlpha = alpha;
this.ctx.fillRect(left, drawStartY, width, wallHeight);
this.ctx.globalAlpha = 1;
}
}
If anyone wants to play with the full version it’s at https://github.com/xtrinch/raycaster/tree/rewrite, can modify the length of the plane vector in function findSpawnPoint