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