Having spent a couple of hours trying to solve this exact problem on a raycasting engine of my own, I would like to give more detailed mathematical background as to why this is the correct answer, since I wasn't entirely convinced at first. Especially since when doing perspective projection, you already have to correct some spherical distortion (fish bowl effect). The effect described here is a completely different effect.
This is what I got in my engine: the camera is in a square room, looking at a corner, at an angle of roughly 45°, with a FOV of 90°. It seems to have a slight spherical distortion. The red lines have been added afterward, it is also much more obvious to see in motion, but making a GIF is a PITA:
Spherical distortion
Here is the same room, same location and angle, but with a FOV of 70°. It is not as noticeable (and again, easier to see in motion):
Same room with FOV=70
The first version of my raycasting engine emitted rays from -FOV/2+camera_angle to FOV/2+camera_angle, with each angle spaced by FOV/SCREEN_WIDTH degrees (in my case SCREEN_WIDTH was 640).
Here is a top-view schema, with SCREEN_WIDTH = 9:
Casting rays
You can see the problem here : when we use a fixed angle, the only thing guaranteed to be constant are those arcs of circle between two rays. But what should be constant are the segments on the projection plane. We can see by using a fixed angle, that the segments get longer the farther from the center.
To solve this, keep in the mind the following parameters :
- FOV = field of view, 90° in this example.
- DIST = distance from camera to projection plane. In my engine I initially choose 50, not knowing better, but it will need to be adjusted depending on the FOV actually.
- SCREEN_WIDTH = width of the screen in pixels, 640 in my example
Knowing this, we can compute what the length of the segments (SEG_LEN) on the projection plane should be, by using some trigonometry in the triangle ABC:
tan(FOV/2) = SCREEN_HALFLEN / DIST
SCREEN_HALFLEN = DIST * tan(FOV/2)
SCREEN_HALFLEN is the length of screen projected on our imaginary plane, to get the SEG_LEN, simply do:
SEG_LEN = SCREEN_HALFLEN / (SCREEN_WIDTH/2)
Knowing the segment length, we can compute the real angles at which rays need to be emitted: given a column x going from 0 to SCREEN_WIDTH-1, the angle should be:
ANGLES[x] = atan(((SEG_LEN * x - SCREEN_HALFLEN) / DIST)
This is the more or less the same formula given by James Hill in his final example. Putting this all together in the engine, it indeed eliminates the spherical distortion:
Spherical distortion corrected
For fun, we can compute what are the differences between a fixed angle raycasting and fixed length raycasting, in the worst case at ray x = 97 where there is a 9 pixels difference:
The angle for the fixed angle raycasting is = 97 * FOV/SCREEN_WIDTH - FOV/2 = -31.359375°
With a fixed length raycasting, the angle is : atan(97 * SEG_LEN / DIST) = -34.871676373193203°
So, up to an 11% error, using given parameters (FOV = 90, DIST = 50, SCREEN_WIDTH = 640).
For reference, I would like to add more detail as to how I implemented this is my engine: for the better or the worse, I wanted to do everything using integer arithmetic (except initialization stuff). First I setup two tables to pre-compute sine and cosine values, using fixed point arithmetic (examples are in C language):
#define FIXEDSHIFT 13
#define FIXEDPRES (1<<FIXEDSHIFT)
#define DIST 50
#define FOV 90
#define SCREEN_WIDTH 640
#define SCREEN_HEIGHT 480
#define HALF_WIDTH (SCREEN_WIDTH/2)
int i;
int size = 360.0 / ((double)FOV / SCREEN_WIDTH)));
int16_t * Cos = malloc(size * sizeof *Cos);
int16_t * Sin = malloc(size * sizeof *Sin);
for (i = 0; i < size; i ++)
{
double angle = i * (2.0*M_PI / size);
Cos[i] = (int16_t)(cos(angle) * FIXEDPRES);
Sin[i] = (int16_t)(sin(angle) * FIXEDPRES);
}
I initially used these tables to also cast rays, which resulted in the first 2 screenshots. So I added the ANGLES table, split into cartesian coordinates:
int16_t * XRay = malloc(SCREEN_WIDTH * sizeof *XRay);
int16_t * YRay = malloc(SCREEN_WIDTH * sizeof *YRay);
double dist = (DIST * tan(FOV*M_PI/360)) / (HALF_WIDTH-1);
for (i = 0; i < HALF_WIDTH; i ++)
{
#if 0
/* for fun, this re-enables the spherical distortion */
double angle = i * (2.0*M_PI / (MAX_TAB));
#else
double angle = atan((dist * i) / DIST);
#endif
XRay[HALF_WIDTH-i-1] = XRay[HALF_WIDTH+i] = (int16_t)(cos(angle) * FIXEDPRES);
YRay[HALF_WIDTH-i-1] = -(YRay[HALF_WIDTH+i] = (int16_t)(sin(angle) * FIXEDPRES));
}
Then in the raycasting engine, to get the correct rays, I used :
int raycasting(int camera_angle)
{
int i;
for (i = 0; i < SCREEN_WIDTH; i ++)
{
int dx = Cos[camera_angle];
int dy = Sin[camera_angle];
/* simply apply a rotation matrix with dx (cos) and dy (sin) */
int xray = (XRay[i] * dx - YRay[i] * dy) >> FIXEDSHIFT;
int yray = (XRay[i] * dy + YRay[i] * dx) >> FIXEDSHIFT;
/* remember that xray and yray are respectively cosine and sine of the current ray */
/* you will need those values to do perspective projection */
/* ... */
}
}