DEV Community

Cover image for Developing a 3D Raycasting Engine from Scratch: Vector Math and Rendering
Ebendttl
Ebendttl

Posted on • Originally published at akinseinde.netlify.app

Developing a 3D Raycasting Engine from Scratch: Vector Math and Rendering

This is an excerpt. The full article includes a live interactive 3D raycasting renderer — control a 2D player model on a grid map using WASD/Arrow controls, inspect visual rays casting in real time, and watch the corresponding 3D viewport update with wall height calculations and shading. Read the full interactive version →


The Birth of retro 3D: John Carmack and Wolfenstein 3D

In 1992, id Software released Wolfenstein 3D, introducing gamers to a fast-paced pseudo-3D world. Because early 90s personal computer hardware lacked the processing power to render true polygon-based 3D models in real time, John Carmack implemented a technique called Raycasting.

Rather than rendering 3D vertices, raycasting projects a simple 2D grid map into a 3D perspective viewport. The engine casts individual rays from the player's coordinate vectors across their Field of View (FOV).

When a ray hits a wall, the engine computes the distance, and uses that distance to determine how tall that segment of the wall should be rendered on screen. By casting one ray for every vertical column of the screen, the engine builds a convincing 3D perspective using basic 2D line rendering.


1. The Camera Vector Space

To calculate ray projections, the engine tracks three main coordinate vectors:

  • Position Vector (pos): The player's coordinate location in the 2D grid space.
  • Direction Vector (dir): A unit vector representing the center line of the player's gaze.
  • Camera Plane (plane): A vector perpendicular to the direction vector, representing the screen viewport window.
                  Camera Plane (plane)
              ┌───────────▲───────────┐
              │           │           │
              │           │           │
              │           │ (dir)     │
              └───────────┼───────────┘
                          │
                          │
                       (player)
Enter fullscreen mode Exit fullscreen mode

By shifting along the camera plane vector, the engine calculates the starting coordinates and direction vector for every ray cast across the screen width.


2. Fast Grid Traversal: The DDA Algorithm

Casting a ray by checking every tiny pixel increment (e.g. 0.01 step checks) is slow and can miss walls due to rounding errors.

To solve this, we use the DDA (Digital Differential Analysis) Algorithm. DDA is a fast grid traversal method that only checks the grid lines the ray crosses.

Instead of walking along the ray, DDA calculates the exact mathematical step distance to the next vertical (X) or horizontal (Y) grid boundary. By checking grid intersections sequentially, the algorithm determines wall hits using only basic additions and boundary compares.

Grid Space:
  ┌───────┬───────┬───────┐
  │       │       │  Hit! │
  │       │       │  /    │
  ├───────┼───────┼─o─────┤  <-- Y boundary crossing
  │       │      /│       │
  │       │     / │       │
  ├───────┼────o──┼───────┤  <-- X boundary crossing
  │ (Player)  /   │       │
  └───────┴───────┴───────┘
Enter fullscreen mode Exit fullscreen mode

3. Correcting the Fish-Eye Distortion Effect

If we use the raw distance from the player to the wall, we get a Fish-eye Distortion effect, where flat walls appear curved.

This happens because rays cast at the outer edges of the FOV are longer than rays cast straight ahead.

To fix this, we project the ray distance onto the player's direction vector, calculating the perpendicular distance to the camera plane instead of the straight-line distance. This projection straightens the walls, ensuring they render correctly without distortion.

Curved Wall (Fish-eye):           Corrected Wall:
    ┌───────────────┐                 ┌───────────────┐
    │  \    |    /  │                 │  |    |    |  │
    │   \   |   /   │                 │  |    |    |  │
    │    \  |  /    │                 │  |    |    |  │
Enter fullscreen mode Exit fullscreen mode

TypeScript Raycasting Implementation

Here is a clean TypeScript class showing how to set up the camera vectors, cast rays, and calculate perpendicular distances:

export interface Camera {
  posX: number;
  posY: number;
  dirX: number; // gaze direction vector
  dirY: number;
  planeX: number; // perpendicular camera plane
  planeY: number;
}

export class RaycasterEngine {
  private map: number[][];

  constructor(gridMap: number[][]) {
    this.map = gridMap;
  }

  /**
   * Casts a single ray for a column offset (-1 to 1) 
   * and calculates the perpendicular distance to the hit wall.
   */
  public castRay(camera: Camera, cameraX: number): { distance: number; side: number } {
    // Ray direction vector
    const rayDirX = camera.dirX + camera.planeX * cameraX;
    const rayDirY = camera.dirY + camera.planeY * cameraX;

    // Grid coordinates
    let mapX = Math.floor(camera.posX);
    let mapY = Math.floor(camera.posY);

    // Delta step distance along ray to cross one grid square boundary
    const deltaDistX = Math.abs(1 / rayDirX);
    const deltaDistY = Math.abs(1 / rayDirY);

    let stepX = 0;
    let stepY = 0;
    let sideDistX = 0;
    let sideDistY = 0;

    // Calculate step directions and initial boundary distances
    if (rayDirX < 0) {
      stepX = -1;
      sideDistX = (camera.posX - mapX) * deltaDistX;
    } else {
      stepX = 1;
      sideDistX = (mapX + 1.0 - camera.posX) * deltaDistX;
    }

    if (rayDirY < 0) {
      stepY = -1;
      sideDistY = (camera.posY - mapY) * deltaDistY;
    } else {
      stepY = 1;
      sideDistY = (mapY + 1.0 - camera.posY) * deltaDistY;
    }

    let hit = 0;
    let side = 0; // 0 for X axis hit, 1 for Y axis hit

    // DDA traversal loop
    while (hit === 0) {
      if (sideDistX < sideDistY) {
        sideDistX += deltaDistX;
        mapX += stepX;
        side = 0;
      } else {
        sideDistY += deltaDistY;
        mapY += stepY;
        side = 1;
      }

      // Check collision
      if (this.map[mapY][mapX] > 0) {
        hit = 1;
      }
    }

    // Calculate perpendicular wall distance (correcting fish-eye effect)
    let perpWallDist = 0;
    if (side === 0) {
      perpWallDist = (mapX - camera.posX + (1 - stepX) / 2) / rayDirX;
    } else {
      perpWallDist = (mapY - camera.posY + (1 - stepY) / 2) / rayDirY;
    }

    return { distance: perpWallDist, side };
  }
}
Enter fullscreen mode Exit fullscreen mode

Engineering Takeaways

  1. High rendering efficiency: Raycasting reduces 3D rendering to 2D line drawing, allowing smooth performance on low-power hardware.
  2. Vector Math core: Clean camera and projection vector math is essential to avoid fish-eye distortion and layout glitches.
  3. Retro styling potential: Raycasters are a great choice for retro game aesthetics, mini-maps, and custom UI elements.

The full article features a live interactive raycasting sandbox — toggle shading depth cues, adjust FOV parameters, and explore a retro 3D maze environment directly in your browser.

Read the full interactive article →


Written by Ebenezer Akinseinde — Software Developer & AI Automations Engineer.

Portfolio · GitHub

Top comments (0)