A 3D raycasting engine inspired by Wolfenstein 3D, implemented in C using the MLX graphics library. This project creates a first-person perspective 3D environment from a 2D map using raycasting techniques.
- Overview
- Features
- Installation
- Usage
- Map Format
- Parsing
- Raycasting Implementation
- Minimap
- Project Structure
- Technical Details
CUB3D is a 3D graphics engine that uses raycasting to create a pseudo-3D environment from a 2D map. The engine casts rays from the player's position in different directions to determine wall distances and render the 3D scene. This technique was famously used in early 3D games like Wolfenstein 3D.
- 3D Raycasting Engine: Real-time 3D rendering using raycasting algorithm
- Texture Mapping: Support for wall textures (North, South, East, West)
- Player Movement: WASD keys for movement, arrow keys for rotation
- Collision Detection: Wall collision detection and boundary checking
- Minimap: 2D overhead view of the map with player position and FOV
- Color Customization: Configurable floor and ceiling colors
- Cross-platform: Support for both Linux and macOS
- GCC compiler
- Make
- MLX graphics library (included in the project)
git clone <repository-url>
cd cub3d
make./cub3d <map_file.cub>- W/A/S/D: Move forward/left/backward/right
- Left/Right Arrow Keys: Rotate view
- ESC: Exit the game
The map file (.cub) contains configuration and the 2D map layout:
NO ./textures/wall_1.xpm # North wall texture
SO ./textures/wall_2.xpm # South wall texture
WE ./textures/wall_3.xpm # West wall texture
EA ./textures/wall_4.xpm # East wall texture
F 255,0,255 # Floor color (RGB)
C 122,141,153 # Ceiling color (RGB)
111111111111111111111 # Map layout
101000001000000000001 # 1 = Wall, 0 = Empty space
101010101011111110001 # N/S/E/W = Player spawn direction
100010100010000010001
111110101111101011101
100000101000101000001
101111101011101111111
101000001010100000101
101011111010111110101
101010001010100010101
101110101010101010101
100000101010001010001
111111101011111011101
100000101000100010001
101110101110101110111
101000100000100010101
101011111111111010101
100010000000000010001
101010111011111111101
1N1000001000000000001 # N = Player spawn facing North
111111111111111111111
The parsing logic is as follows:
(0)
Quick file-check for correct extension and readable file.
(1)
Using get_next_line2, we open and read the file a first time to add the textures, colors and the map dimensions in a t_game structure.
ALL texture and color formatting errors are handled here:
- Correct identifiers for textures and colors.
- Openable paths for textures.
- Correct formatting for colors (r,g,b with 0 <= (r,g,b) <= 255).
- Empty lines between every type of element are ok.
- Textures and colors can be listed then read in any order.
- Spaces between each element's information are ok.
- No repeated identifiers.
- No missing elements.
- No unkown identifiers or elements.
- Textures and colors must come before the map content.
The following map specifics are checked:
- Lines between the textures/colors and game map are empty.
- Map content consists of only {0,1,N,S,W,E} or spaces.
- There is only one player.
- No empty lines within the map content.
- No unknown element(s) found in the map.
If an error is found before the end of file, everything is destroyed, the file is closed, and the program stops here.
If no error is found after the end of file, we close the file.
(2)
After finding the map dimensions in (1), we can allocate the right amount of memory for the map.
Using get_next_line2, we open and read the file a second time to add the map content in the t_game structure.
We close the file.
(3) Once the map content has been added, we can check if the map is playable.
Flood-filling:
Starting at the player position, it marks the current element in the map as VISITED if **it is not a wall and not already visited**.
Then it moves north, south, east and west and repeats.
If the current position is a wall, return.
If the current position is at the border and is a floor, the map is not closed.
The raycasting engine is the core of this project. Here's a detailed explanation of how it works:
- FOV: 60 degrees (configurable in
raycasting.h) - Ray Count: One ray per pixel column (1600 rays for 1600px width)
- Ray Angle Increment:
FOV / WINDOW_Xdegrees between rays
typedef struct raycasting
{
double pos_x; // Player X position in pixels
double pos_y; // Player Y position in pixels
double angle_start; // Leftmost ray angle
double angle_end; // Rightmost ray angle
double middle_fov_angle; // Center ray angle
double ray_angle; // Current ray angle
double subsequent_angle; // Angle increment between rays
bool horizontal_distance_chosen; // Which intersection was chosen
t_proj proj_plan; // Projection plane data
t_block point_hit; // Wall intersection point
double projected_wall_height; // Wall height on screen
double distance_to_wall; // Distance to wall
double horizontal_wall_size_in_px; // Wall size in pixels
} t_player;
The algorithm follows these steps for each ray:
// In draw_every_ray()
player->ray_angle = player->angle_start;
player->subsequent_angle = FOV / PROJ_PLANE_X; // 60° / 1600 = 0.0375°
while (player->ray_angle > player->angle_end)
{
// Cast ray and calculate distance
player->distance_to_wall = calculate_best_distance(vars);
// Draw the vertical line
draw_raycasting(vars, &x, &y);
player->ray_angle -= player->subsequent_angle;
}The engine uses DDA to find wall intersections efficiently:
Horizontal Intersection (horizontal_intersection.c):
t_block horizontal_point_crossing_wall(t_vars *vars)
{
// Find first horizontal grid line intersection
t_block first_point = find_coordinate_of_first_horizontal_point(vars);
// Step along horizontal grid lines until hitting a wall
while (map[y][x] == 'V' && wall_not_in_diagonal(vars, current_block))
{
next_point = find_next_horizontal_point(current_point, ray_angle);
// Check if next point is a wall
}
return intersection_point;
}Vertical Intersection (vertical_intersection.c):
t_block vertical_point_crossing_wall(t_vars *vars)
{
// Find first vertical grid line intersection
t_block first_point = find_coordinate_of_first_vertical_point(vars);
// Step along vertical grid lines until hitting a wall
while (map[y][x] == 'V' && wall_not_in_diagonal(vars, current_block))
{
next_point = find_next_vertical_point(current_point, ray_angle);
// Check if next point is a wall
}
return intersection_point;
}For each ray, both horizontal and vertical intersections are calculated, then the shortest distance is chosen:
double calculate_best_distance(t_vars *vars)
{
t_block horizontal_pt = horizontal_point_crossing_wall(vars);
double horizontal_distance = calculate_hypo_distance(vars, horizontal_pt);
t_block vertical_pt = vertical_point_crossing_wall(vars);
double vertical_distance = calculate_hypo_distance(vars, vertical_pt);
// Choose the shorter distance
if (vertical_distance < horizontal_distance)
{
vars->game->player->horizontal_distance_chosen = false;
return vertical_distance;
}
else
{
vars->game->player->horizontal_distance_chosen = true;
return horizontal_distance;
}
}The fishbowl effect is corrected using the cosine of the angle difference:
static double calculate_hypo_distance(t_vars *vars, t_block point)
{
double distance = sqrt((pos_x - point.x)² + (pos_y - point.y)²);
double beta = middle_fov_angle - ray_angle;
return distance * cos(beta * (PI / 180.0));
}Wall height is calculated using similar triangles:
static double calculate_projected_wall_height(t_vars *vars)
{
double actual_wall_height = BLOCK_SIZE; // 64 pixels
double projected_wall_height = (actual_wall_height / distance_to_wall)
* distance_player_pplan;
return projected_wall_height;
}Each ray draws a vertical line (column) on the screen:
static void draw_raycasting(t_vars *vars, int *x, int *y)
{
// Calculate wall top and bottom positions
wall_top = center_y - projected_height / 2;
wall_bottom = center_y + projected_height / 2;
// Draw ceiling, wall, and floor
for (int y = 0; y < WINDOW_Y; y++)
{
if (y < wall_top)
draw_ceiling_pixel(x, y);
else if (y >= wall_top && y <= wall_bottom)
draw_wall_pixel(x, y); // With texture mapping
else
draw_floor_pixel(x, y);
}
}- Tangent: Used to find intersection points with grid lines
- Cosine: Used for fishbowl effect correction
- Pythagorean Theorem: Used to calculate distances
- World Coordinates: Player position in pixels
- Grid Coordinates: Map array indices
- Screen Coordinates: Pixel positions on the display
- Distance to Projection Plane:
(PROJ_PLANE_X/2) / tan(FOV/2) - Wall Height Formula:
(actual_height / distance) * projection_distance
draw_minimap is called once at the start of the program, then everytime update_player is called.
The update_player function is called everytime the player moves, or looks around.
The 2D minimap consists of:
- a background that does not change, it depends only on the parsed .cub map. We loop through the map and apply draw_minimap_tile on each tile of the map.
- a FOV (Field Of View) that depends only on the values retrieved from simon's raycasting (which depend on the position/orientation). We use the function called draw_minimap_fov.
- the player representation depends on his position in the map. We need to define the player's width and height, so that collisions are properly handled and accurate with raycasting. We use the function called draw_minimap_player.
draw_minimap: draws the complete minimap.
Loops through the initial map, and uses draw_minimap_tile for each element
of the map to draw the map without the player or the FOV.
Adds the FOV using draw_minimap_fov.
Adds the player using draw_minimap_player.
draw_minimap_tile: Draws a single tile in minimap.
It draws a size_x * size_y tile representing a floor or a wall
depending on the position and the background color of the map.
If it's a floor, draw a (sixe_x * size_y) tile contrasting with the ceiling color.
If it's a wall, draw a (sixe_x * size_y) tile opposite to the color used above.
draw_minimap_player: Draws the player on the minimap.
It draws a (size_x / 2) * (size_y / 2) contrasting tile representing the player.
These dimensions are equal to 1/2 of a wall or floor tile. Decreasing its size
allows the player to move more freely in tight spaces.
Since player's starting position is set as the center of the starting tile,
We have to make the loop start at:
The scaled starting x minus a 1/4 of size_x (i.e. 1/2 of player width)
The scaled starting y minus a 1/4 of size_y (i.e. 1/2 of player height)
draw_minimap_fov: Draws the FOV on the minimap.
It uses the same loop logic as draw_every_ray to draw the FOV in 2D.
That is, for each subsequent angle between angle_start and angle_end, it
draws a line at that angle until it hits the closest wall in that direction.
To draw the line, it scales the result of return_intersection.
FYI: return_intersection returns a point representing the intersection with
a wall on the map. However, its coordinates are scaled BLOCK_SIZE times.
So for each subsequent angle, the result of return_intersection is scaled back
to the original map dimensions, then re-scaled to the minimap dimensions.
cub3d/
├── srcs/
│ ├── main.c # Main program entry point
│ ├── raycasting/ # Core raycasting engine
│ │ ├── drawing_raycasting.c # Main rendering loop
│ │ ├── calculate_best_distance.c # Distance calculations
│ │ ├── horizontal_intersection.c # Horizontal DDA
│ │ ├── vertical_intersection.c # Vertical DDA
│ │ ├── raycasting_utils.c # Utility functions
│ │ └── draw_textures.c # Texture rendering
│ ├── parsing/ # Map and config parsing
│ ├── controls/ # Input handling and movement
│ ├── minimap/ # 2D minimap rendering
│ └── colors/ # Color utilities
├── headers/
│ ├── cub3d.h # Main header with structures
│ ├── raycasting.h # Raycasting-specific definitions
│ ├── parsing.h # Parsing structures
│ ├── textures.h # Texture handling
│ └── colors.h # Color definitions
├── maps/ # Map files (.cub format)
├── textures/ # Wall texture images
├── mlx/ # MLX graphics library (Linux)
├── mlx_macOS/ # MLX graphics library (macOS)
└── Makefile # Build configuration
- DDA Algorithm: Efficient grid traversal without floating-point divisions
- Distance Caching: Stores calculated distances to avoid recalculation
- Early Exit: Stops ray casting when hitting walls
- Dynamic Allocation: Map and texture data allocated dynamically
- Proper Cleanup: All resources freed on program exit
- Error Handling: Graceful handling of file and memory errors
- Conditional Compilation: Different MLX paths for Linux/macOS
- Key Mapping: Platform-specific key codes
- Library Linking: Appropriate library linking for each platform
This implementation is based on the raycasting tutorial by Permadi:
