Raycasting 기반 3D 게임 (cub3d)
2025년 7월 6일
cub3D #
프로젝트 설명 #
C언어와 mlx 라이브러리를 사용하여 Raycasting 기반의 3d 그래픽 게임을 구현
실행 방법 (only Mac) #
1# m1 mac : arch -x86_64 make
2$ make
3$ ./cub3D ./maps/map.cub
1. 맵 파싱 #
SO,NO,EA,WE: 벽의 방향에 맞는 이미지 경로F,C: 천장과 바닥의 RGB 색- 리소스 설정 이후에는
1(벽)과0(땅) 으로 이뤄진 2차원 맵이 온다. N E W S로 캐릭터의 위치와 처음 시작 방향을 결정할 수 있다.d,D: 열린 문, 닫힌 문
2. 키보드 조작 #
캐릭터 이동 (WASD), 방향 전환 (←, →), 종료(ESC)
3. 마우스 입력으로 방향 전환 #
4. 미니맵 (TAB키를 이용해서 on/off) #
5. space 키로 개폐 가능한 문 #
개발계획 및 구조 #
역할분담 #
- 김동현(팀장) : 맵 파싱, 캐릭터 이동, 레이캐스팅, 미니맵, 텍스쳐 설정, 문 개폐 기능
- 이아랑(팀원) : 구조체 정의, 맵 파싱
작업일정 #
- 2022.07.20 ~ 07.23 : 구현방법 회의, 맵 파싱 구현
- 2022.07.24 ~ 07.25 : 미니맵, 키보드 입력, 캐릭터 이동 구현
- 2022.07.26 ~ 07.29 : 레이캐스팅 구현
- 2022.07.30 ~ 08.02 : 벽 그리기
- 2022.08.03 ~ 08.04 : 텍스쳐 이미지 입히기, 오류 수정
- 2022.08.05 ~ 08.06 : 문 개폐, 마우스 방향 전환 기능 추가
폴더 트리 #
1assets/ # 텍스쳐 이미지 파일
2maps/ # 맵 설정 파일
3base/ # 가장 먼저 실행되는 파일
4parse/ # 맵 파일 파싱 함수
5engine/ # 게임 동작 관련 함수들 (캐릭터 이동, 미니맵, 레이캐스팅 등)
6get_next_line/, libft/, utils/ # 유틸 함수들이 포함된 폴더
7mlx/ # mlx 라이브러리 폴더
8mlx_utils/ # 화면 출력 관련 유틸 함수들이 포함된 폴더
초기화 #
mlx 라이브러리 #
구조체 #
1typedef struct s_mlx
2{
3 void *mlx;
4 void *mlx_win; // 실제 디스플레이와 연결된 window
5 t_img img[2]; // 프레임버퍼
6 int img_idx; // 현재 버퍼 이미지 인덱스
7} t_mlx;
화면 출력방법 #
mlx 라이브러리는 mlx_loop_hook 함수에 등록해두면, mlx_loop 함수 안에서 해당 함수를 매 루프마다 실행시켜주고, mlx_put_image_to_window 함수를 통해서 지정한 window에 색상 바이트 배열로 이뤄진 image를 출력할 수 있다.
1static int loop_main(t_cub3d *p_data)
2{
3 ...
4 // mlx.img는 프레임버퍼이고, mlx_win 이 출력화면과 연결되어 있다. in
5 mlx_put_image_to_window(p_data->mlx.mlx,
6 p_data->mlx.mlx_win,
7 p_data->mlx.img[p_data->mlx.img_idx].img, 0, 0);
8 ...
9 return (0);
10}
11
12int main(int argc, char *argv[])
13{
14 ...
15 // 프레임마다 실행할 함수
16 mlx_loop_hook(data.mlx.mlx, loop_main, &data);
17 mlx_loop(data.mlx.mlx);
18 return (0);
19}
맵 파일 파싱 #
맵 파일은 maps/ 폴더에 저장되며, *.cub 확장자이다.
텍스쳐 이미지, 천장, 바닥 색 설정 부분과 2차원 맵 컨텐츠 부분으로 나뉘어 있다.
asset 데이터 파싱 #
이미지, 색 설정 부분은 일정하지 않은 공백이나 기호의 순서가 변하더라도 처리할 수 있도록 구현되어 있다.
기호를 flag와 count로 관리하여 부족하거나 중복된 경우 에러가 출력된다.
소스코드
1int parse_data(t_cub3d *p_data)
2{
3 // 한줄 씩 파싱하며, 총 6개의 데이터가 파싱될 때까지 반복
4 // 파싱된 데이터는 플래그를 세팅해둔다.
5 while (idx < p_data->file_line && parse_cnt < 6)
6 {
7 parse_type = parse_line(p_data->file_ptr[idx], p_data);
8 if (parse_type == PARSE_ERROR)
9 ft_exit("Error\nInvalid parse symbol", 1);
10 if (parse_type != PARSE_NONE)
11 parse_cnt++;
12 p_data->parse_flag |= parse_type;
13 idx++;
14 }
15 // 파싱이 완료됐는지 체크 (모든 플래그가 켜져있는지)
16 if (p_data->parse_flag != PARSE_FINISH)
17 // ...
18}
맵 데이터 파싱 #
2차원 맵 컨텐츠 데이터를 구조체에 세팅하는 함수이다. 소스코드
1void content_checker(t_cub3d *p_data)
2{
3 if (!set_content_data(p_data))
4 ft_exit("Error\nInvalid map content", 1);
5 p_data->content_data.content_ptr = content_malloc(p_data, -0x30);
6 if (p_data->content_data.content_ptr == 0)
7 ft_exit("Error\nMemory allocation error", 1);
8 set_content(p_data); // 맵을 메모리에 세팅
9 content_close_check(p_data); // 맵이 닫힌 상태인지 체크
10 set_player(p_data); // 플레이어 위치와 방향 세팅
11}
content_close_check 함수에서 플레이어 기준으로 맵이 닫힌 상태인지 검증한다.
맵 컨텐츠는 모든 땅이 벽으로 둘러싸여 있어야 정상적인 맵이기 때문에 DFS 알고리즘으로 모든 땅(0) 이 벽(1) 로 둘러싸여 있는지 확인했고, NEWS(캐릭터), 0, 1, 공백, D 이외의 문자가 온다면 에러로 처리한다.
1void dfs_content_check(t_cub3d *p_data, char **visit_ptr, int y, int x)
2{
3 if (!(x >= 0 && y >= 0
4 && x <= p_data->content_data.content_len - 1
5 && y <= p_data->content_data.content_line - 1))
6 return ;
7 if (p_data->content_data.content_ptr[y][x] == 1)
8 return ;
9 if (visit_ptr[y][x] == 1)
10 return ;
11 if (p_data->content_data.content_ptr[y][x] < 0)
12 ft_exit("Error\nInvalid map content", 1);
13 visit_ptr[y][x] = 1;
14 dfs_content_check(p_data, visit_ptr, y - 1, x);
15 dfs_content_check(p_data, visit_ptr, y + 1, x);
16 dfs_content_check(p_data, visit_ptr, y, x - 1);
17 dfs_content_check(p_data, visit_ptr, y, x + 1);
18}
그리기 #
유틸함수 #
Image 의 원하는 좌표에 원하는 색상을 넣기 위해 mlx_pixel_to_image 를 구현했고, 범용적으로 사용되는 mlx_draw_square, mlx_draw_line 등의 함수를 구현함
소스코드
1// x, y 좌표에 해당하는 컬러 값을 가져오고 dst에서 해당되는 좌표에 칠한다.
2void mlx_pixel_to_image(t_img *img, unsigned int x, unsigned int y,
3 unsigned int color)
4{
5 char *dst;
6 unsigned int bitmask;
7
8 if (x < img->width && y < img->height)
9 {
10 bitmask = 0xFFFFFFFF >> (32 - img->bits_per_pixel);
11 dst = img->addr
12 + (y * img->line_length + x * (img->bits_per_pixel / 8));
13 *(unsigned int *)dst = color & bitmask;
14 }
15}
mlx_draw_line 은 s_pos에서 d_pos로 도착하는 직선을 긋는 함수인데,
기울기가 1 이하인 경우 x축으로 이동하며 직선의 방정식 값을 넘었는지 체크하고 y축 좌표 증가 여부를 정하는 Bresenham 알고리즘 사용
레이캐스팅 #
광선의 방향 #
광선은 화면에서의 x 픽셀 좌표를 이터레이션하며 한번씩 쏘게 된다.
각도는 라디안으로 나오면 레이캐스팅을 사용할 수 있고, 픽셀당 라디안을 구한 후 플레이어가 보는 방향이 중앙이 오도록 하면 된다.
1// 라디안 계산은 PI/180 이고, 시야각을 전체 가로픽셀을 나누면(FOV를 픽셀만큼 잘개쪼갬) 픽셀당 라디안 값이 나온다.
2rad_per_pixel = PI/180 * FOV/CAM_WIDTH_PIXEL;
3start_radian = player->radian - rad_per_pixel * CAM_WIDTH_PIXEL/2;
4while (i < CAM_WIDTH_PIXEL)
5{
6 raycast_function(start_radian);
7 draw_vertical();
8 start_radian += rad_per_pixel;
9}
광선의 거리 #
레이캐스팅은 캐릭터를 기준으로 모든 x좌표에 광선을 쏴서 벽이 닿으면 거리를 측정하는 방식이고, 프레임마다 벽이 닿았는지 검사하는것은 효율이 낮기 때문에 경계면을 검사한다.
- sideDistY : 현재 위치에서 광선의방향으로 가장먼저 닿는 x축 좌표까지의 Ray 거리
- sideDistX : 현재 위치에서 가장먼저 닿는 Y축 좌표 까지의 Ray 거리
- deltaDistY : Y축으로 1만큼 이동했을때 Ray 거리
- deltaDistX : X축으로 1만큼 이동했을떄 Ray 거리
sideDist 에는 각각 현재 위치부터 다음 경계면까지의 광선 길이가 저장되는데, 값이 작을수록 먼저 도달할 것이다.
먼저 도달하는 곳에서는 또 다음 도달했을때 길이를 계산하고 맵에서 장애물을 만날때까지 반복한다.
1// 장애물은 경계면의 뒤에 있기 때문에 dir 0 에서 나가지면 광선이 x좌표 경계면에 닿은것이다.
2while (map[x][y] != 1)
3{
4 if (sideDistX < sideDistY)
5 {
6 sideDistX += deltaDistX;
7 x+=x_dir;
8 dir = 0;
9 }
10 else
11 {
12 sideDistY += deltaDistY;
13 y+=y_dir
14 dir = 1;
15 }
16}
17// 마지막 닿은 방향이 x라면 직전 경계 길이인 sideDistX - deltaDistX 가 광선의 길이가 된다.
18int ray_distance = dir == 0 ? sideDistX : sideDistY;
레이캐스팅으로 세로길이 구하기 #
모든 x축 좌표에 대해 벽까지의 거리를 Raycasting 으로 계산하고, 화면의 중앙을 기준으로 거리에 따라 세로 선의 길이를 다르게 그리는 방식으로 3D의 입체감을 표현할 수 있다.
하나의 벽이 정사각형이길 원한다면 가로세로 화면 비율에 맞춰서 FOV를 계산해야한다.
가로2: 세로1 인 경우 시야각을 90도로 해야 가로에 상자 2개가 딱 들어오는 거리에서 세로 한칸에 상자 1개가 딱 들어올 것이다.
어안렌즈 효과 #
레이캐스팅을 정직하게 사용하면 어안렌즈 효과라는게 발생한다.
플레이어 기준으로 계산했기 때문에 같은 벽이라도 거리가 달라지는 것이 원인이고, 카메라 기준으로 거리를 계산하게되면 벽의 높이가 같아진다.
텍스쳐 이미지 입히기 #
텍스쳐의 x 축 #
광선이 화면으로부터 쏴지고, 화면에서 보이는 벽에 닿게되면 거리에 따라 높이를 계산해 세로 줄을 그리게된다.
텍스쳐는 초기화 단계에서 메모리에 올라와있고, 이미 벽까지의 거리를 계산해야 하기 때문에 광선이 벽에 닿을때 x, y 좌표가 소수점 단위까지 나온다.
여기에서 소수점만 챙기면 닿은 벽에서 어느정도의 위치에 광선이 닿았는지 알 수 있다.
텍스쳐는 바라보는 방향에서 왼쪽에서 오른쪽으로 보이게 해야하기 때문에 북쪽과 동쪽에선 뒤집어서 그려야한다. 1 - (x - (int)x)
이건 텍스쳐에서 x 축을 계산한 것이다.
텍스쳐의 y 축 #
이제 텍스쳐에서 하나의 세로 줄을 그리는 함수를 구현해야하는데, 아주 가까이 붙지만 않으면 벽의 세로가 화면에 다 보이게 된다.
가까이 붙은경우 화면에 그려지는 시작점과 끝점이 텍스쳐의 시작점과 끝점이 아니게된다.
완성된 코드 #
벽에서 광선이 맞은 지점으로 텍스쳐의 x축 지점을 찾고, 거리에 따른 세로길이와 화면의 세로길이의 비율로 y축 시작지점과 끝지점을 찾아서 그리면 된다.
1// 벽 텍스쳐에서 현재 x, y 좌표에 따라 색을 선택하는 함수
2static unsigned int get_color_in_texture(t_img *wall, double x, double y)
3{
4 unsigned int color;
5 char *dst;
6
7 dst = wall->addr + (wall->line_length * (unsigned int)(wall->height * y)
8 + (wall->bits_per_pixel / 8) * (unsigned int)(wall->width * x));
9 color = *(unsigned int *)dst;
10 return (color);
11}
12
13// 세로 한줄을 그리는 함수
14static void draw_texture_vertical(t_cub3d *p_data, int *pos,
15 t_draw_wall *wall)
16{
17 t_img *xpm;
18 unsigned int i;
19 double xpos;
20 int idx;
21
22 // 광선이 닿은 벽의 방향에 따라 x의 시작지점과 이미지가 달라진다.
23 idx = p_data->mlx.img_idx;
24 if (wall->wall_dir == WALL_SO || wall->wall_dir == WALL_DOOR_X)
25 xpos = wall->ray_wall_dpos[0] - (int)wall->ray_wall_dpos[0];
26 else if (wall->wall_dir == WALL_NO)
27 xpos = 1 - (wall->ray_wall_dpos[0] - (int)wall->ray_wall_dpos[0]);
28 else if (wall->wall_dir == WALL_WE || wall->wall_dir == WALL_DOOR_Y)
29 xpos = wall->ray_wall_dpos[1] - (int)wall->ray_wall_dpos[1];
30 else if (wall->wall_dir == WALL_EA)
31 xpos = 1 - (wall->ray_wall_dpos[1] - (int)wall->ray_wall_dpos[1]);
32 if (wall->wall_dir == WALL_DOOR_X || wall->wall_dir == WALL_DOOR_Y)
33 wall->wall_dir = WALL_DOOR;
34 xpm = &p_data->xpm_data[wall->wall_dir];
35 // 텍스쳐에서 현재 광선에 맞는 색을 선택해서 프레임버퍼에 1픽셀씩 전체 세로줄을 찍는다.
36 // i 는 화면의 y 픽셀좌표이고, 이걸 벽의 화면성 세로길이로 나눠서 몇퍼센트 지점에 있는 픽셀인지 계산한다.
37 i = -1;
38 while (++i < wall->vertical_len && i < WIN_HEIGHT)
39 {
40 mlx_pixel_to_image(&p_data->mlx.img[idx], \
41 pos[0], (pos[1] >= 0) * pos[1] + i, \
42 get_color_in_texture(xpm, xpos, \
43 (double)(i + ((pos[1] < 0) * pos[1] * -1)) / wall->vertical_len));
44 }
45}
키보드 입력 #
플레이어 #
캐릭터 이동거리 #
맵 파싱이 완료되면 캐릭터 위치와 방향(radian)이 실수형으로 저장된다.
1typedef struct s_pos
2{
3 double x;
4 double y;
5} t_pos;
6
7typedef struct s_player_data
8{
9 t_pos pos;
10 double radian;
11} t_player_data;
캐릭터의 방향이 radian으로 정해지는데, 어떤 방향으로 이동하든 1초에 1만큼 움직이게 만들어야한다.
radian에 따라 x와 y좌표 모두 1에 sin 비율, cos비율을 곱해서 움직이면 된다.
1static void move_player_calculator(t_player_data *player,
2 int x_cor, int y_cor, double radian)
3{
4 player->xpos += (x_cor * cos(radian)) * 0.1; // 1이 미니맵상 1칸이기 때문에 0.1은 이동속도 보정
5 player->ypos += (y_cor * sin(radian)) * 0.1;
6}
동시 키 입력 #
mlx_hook과 mlx_loop는 실행 빈도가 다르다. 메인루프는 주기적으로 실행되지만, OS에서 키 반복 설정에 따라 이벤트 큐에 키보드 이벤트를 넣어줄때만 반응하기 때문이다.
키보드 입력은 키가 press 상태인지, release 상태인지에 따라 배열로 저장하고, 매 프레임마다 배열을 검사해서 mlx_loop 에서 캐릭터를 이동시킨다.
소스코드
벽 충돌 #
x축은 1*cos(방향), y축은 1*sin(방향) 단위로 이동하여 어떤 방향이든 1 단위로 이동하게 되고, x축과 y축을 따로 검사하여 벽에 닿았을 때 미끄러지듯 이동된다.
소스코드
1static void move_player_calculator(t_cub3d *p_data,
2 int x_cor, int y_cor, double radian)
3{
4 double tmp;
5 t_player_data *player;
6 t_content_data *content;
7 char tile;
8
9 player = &p_data->player;
10 content = &p_data->content_data;
11 tmp = player->pos.x + (x_cor * cos(radian)) * 0.001 * PLAYER_SPEED;
12 tile = content->content_ptr[(unsigned int)player->pos.y][(unsigned int)tmp];
13 // x값이 벽에 충돌한 경우 이전 값으로 복원
14 if (tile != TILE_WALL && !(tile >= TILE_DOOR_C && tile < TILE_DOOR_O))
15 player->pos.x = tmp;
16 tmp = player->pos.y + (y_cor * sin(radian)) * 0.001 * PLAYER_SPEED;
17 tile = content->content_ptr[(unsigned int)tmp][(unsigned int)player->pos.x];
18 // y값이 벽에 충돌한 경우 이전 값으로 복원
19 if (tile != TILE_WALL && !(tile >= TILE_DOOR_C && tile < TILE_DOOR_O))
20 player->pos.y = tmp;
21 p_data->update = 1;
22}
추가기능 #
미니맵 #
미니맵은 두가지 버전으로 컴파일 시점에 정할 수 있고, 시야각도 표시되도록 구현함
버전 1, 타일크기 10
전체 맵이 보이는 버전
버전 2, 타일크기 30
주변만 보이는 버전
개폐 가능한 문 #
문은 바라보는 방향의 거리가 1 아래인 경우 space 키로 개폐가 가능하다.
문이 열리고 닫힐 때 애니메이션이 있어야하고, 문이 열리는 도중 문 뒷편의 벽이 보이지만 애니메이션이 사라지기 전까진 충돌판정이 남아있도록 해야한다.
TILE_DOOR_C와 TILE_DOOR_O 상수 값의 차이를 32만큼 나게 했고, mlx_loop 에서 20 프레임마다 door_status 값을 더해서 총 20*32 프레임이 지날때 문이 열리거나 닫히는게 완료되도록 구현했다.
1void door_operator(t_cub3d *p_data)
2{
3 char *c;
4
5 c = &p_data->content_data.content_ptr \
6 [p_data->op_door.door_pos[1]][p_data->op_door.door_pos[0]];
7 *c += p_data->op_door.door_status; // door_status = 1 or -1
8 p_data->update = 1;
9 if (*c == TILE_DOOR_C || *c == TILE_DOOR_O)
10 p_data->op_door.door_status = 0;
11}
Raycasting 과정에서 문까지의 거리와 벽까지의 거리를 따로 재면 문이 열리고 닫히는 중에는 문 뒤의 벽도 보이도록할 수 있다.
수정할 부분 #
아주 오래전에 개발한 프로젝트지만, 정리하면서 수정할 부분이 많이 보였습니다.
나중에 min-os로 포팅하면서 수정할 예정입니다.
- 비효율적인 맵 파싱. 캐릭터 따로 맵 데이터 따로 파싱해서..
- 프레임 단위로 조금씩 움직여서 상관 없겠지만, 벽 충돌 시 이전 값으로 움직이는게 아니라 움직일 수 있는 최댓값으로 이동해야함.
- 프레임이 델타타임이 아니기때문에 부하가 많이 걸릴수록 속도가 달라진다.