개요

C 언어로 MinilibX(이하 mlx) 를 이용해 가상의 3차원 그래픽을 만드는 프로젝트이다.
그래픽은 1인칭으로 미로를 움직이는 것을 구현하면 된다. (Wolfenstein 3D 처럼)

완성품 (2.5배속, 8fps 인코딩)

2인이 팀이 되어 프로젝트를 진행하였고, 나는 Raycasting 구현과 mlx 컨트롤 일부를 담당하였다.
팀원은 map 파일 에러 검증 및 파싱, mlx hook, mlx loop 설계를 담당했다.


상세한 요구사항은 다음 문서에 담겨있다.

요구사항

  -by 42seoul-translation


구현한 코드는 다음 GitHub 레포지토리에 가면 확인할 수 있다.

Repository


진행 기간

2022.08.03 ~ 2022.08.15

사전 준비

2022.08.03 ~ 2022.08.08

참고할 사이트 목록

요구사항
https://github.com/42seoul-translation/subject_ko/blob/master/cub3d/cub3 d.ko.md

  -by 42seoul-translation

과제 기초 개념
https://kirkim.github.io/42seoul/2021/04/15/cub3d1.html

구현에 대한 아이디어

ray casting

폐기

일단 구현은 2D 로 구현된 맵을 바탕으로 내 위치와 랜더링 대상과의 거리를 이용해 가로 1픽셀 사이즈의 세로줄들을 화면에 뿌려주어야 한다. 거리를 알려면, 랜더링 대상을 특정해야한다. 내가 원하는 가로 1px 사이즈의 공간에 장애물이 있는지 여부를 체크하는 방법에 대한 아이디어를 구상해 보았다. 1. 일단 맵 정보가 담아야 할 것은 장애물의 좌표가 아닌 벽의 (가로 중심의) 좌표이다.
1. input된 맵 인식
2. 죄표계 이동, 장애물을 wall 으로 치환
3. 중복된 정보 탈락 및 새로운 좌표계에 wall 정보 매핑
2. 내가 서있는 공간의 위치에서 2차원 벡터를 하나 발사해서 2D로 구현된 맵에서 마주치는 첫번째 오브젝트 정보를 가져온다. 1. 마주치는지 여부를 확인하는 것은 오브젝트들은 격자 위에 표현되어 있고, 벡터가 어떤 격자를 지날 때마다 그 격자의 주인이 오브젝트인지 빈 공간인지 판별을 한다. 그림을 보면 조금 더 이해가 쉬울 듯 하다.
방향백터와 wall 인식

폐기 사유: 굳이 object 를 wall 으로 변경하여 연산을 복잡하게 할 이유가 없다.

프로그램 진행 순서

전체적 순서

  1. input map file 오류 검증

  2. map 과 asset file 들을 내부 데이터로 parsing

  3. 랜더링 루프

    1. 현재 위치와 시야각, 방향을 토대로 화면 렌더링
    2. 사용자 입력 대기
    3. 사용자 입력 처리 (위치, 시야 등 내부 데이터 변경)
  4. 내부 데이터 메모리 해제 및 종료

데이터 구조

  1. 벡터 구조체

    벡터는 크기 정보를 담아야 하지만, 우리는 필요하지 않다. (단위 벡터 사용)
    대신 벡터의 시작점에 대한 정보를 담는다.

    • 방향정보 (double x, double y)

    • 시작점 (double x, double y)

    용도

    • 플레이어 정보

    • 시선에 대한 정보

  2. 맵 구조체

    • char ** 형태를 갖는 맵 데이터

      map 은 가로 1줄에 대한 정보(char *)를 보관하는 char * 형 배열

  3. 렌더링 정보 구조체

    렌더링 할 벡터와 맵을 이용해 해당 가로 1픽셀에 랜더링 할 정보를 담은 구조체

    • 렌더링 대상 sprite 이미지의 주소

    • 렌더링 대상 이미지에서 x 축 위치

    • 플레이어와 대상과의 거리 (명암조절 용도)

  4. mlx 구조체

    렌더링 할 창에 대한 정보를 담을 구조체

    • mlx 의 포인터

    • mlx_window 의 포인터

    • 바닥 색상

    • 천장 색상

    • 렌더링 오브젝트들 mlx 이미지 포인터

맵 저장 형식

char **map 에 맵 정보가 저장된다.
map[0] 부터 map[n] 까지 가로 1줄에 대한 정보(char *형)가 저장된다.
맵 안의 빈 공간은 0, 벽은 1, 플레이어 위치는 {N, W, S, E}, 맵 밖의 아무것도 없는 빈공간은 E 으로 저장한다.
input으로 들어온 라인에 ‘ ‘, ‘0’, ‘1’, {N, W, S, E} 가 아닌 문자가 있다면 Error 를 출력하고 종료한다.
map[0] 와 mapx 에 ‘.‘으로 가득찬 문자열을 추가한다.
map[n][0] 와 map[n][k] (각 행 마지막 열) 에 ‘.’ 문자를 추가한다.
=> 맵은 ‘.‘으로 둘러 쌓여있게된다. (검증 용이성을 위한 조치)
플레이어 위치를 뜻하는 문자, {N, W, S, E} 가 1개가 아닐 경우 Error 를 출력하고 종료한다.

맵 오류 검증

  1. map[n] 의 모든 ‘.‘에 대해 8방의 모든 문자가(존재한다면) 1 또는 ‘.‘인지 검사한다.

    만약 아니라면 Error 를 출력하고 종료한다.

사용자의 위치, 시선 표현 방법

다른 방식으로 수정됨 (주요 구현 아이디어 참고)

사용자의 위치는 double형 (p_x, p_y) 좌표로 표현된다. 사용자의 시선 중심을 double형 (s_x, s_y) 좌표로 표현한다. 시야각을 A(도) 라고 하고, 창의 가로 크기를 W(px) 라고 할 때,
1번째 픽셀부터 W 번째 픽셀까지 세로줄들을 렌더링해야 한다. n번째 가로 픽셀을 렌더링하기 위해 사용자의 위치와 n 번째 픽셀의 중심 좌표를 잇는 방향 성분이 해당 가로 픽셀을 처리하기 위한 시선이 된다.

구현

본인 작업 기록
2022.08.08 ~ 2022.08.15

2022.08.08 (월)

작업 시작

  • 남은 작업

    • RayCasting 을 위해 필요한 구조체 정의
    • input된 map data와 player data 를 기반으로 랜더링 직전까지 작업 구현.

2022.08.09 (화)

RayCasting 을 위해 필요한 구조체 정의

input된 map data와 player data 를 기반으로 랜더링 직전까지 작업 구현.

  • 남은 작업

    • map 구조체 동료와 통일
    • 기타 구조체, 단위 통일, 좌표계 통일
    • render_source 구조체에 (상하좌우)이미지 주소 할당하기

2022.08.10 (수)

map 구조체 동료와 통일

기타 구조체, 단위 통일, 좌표계 통일

  • 남은 작업

    • render_source 구조체에 (상하좌우)이미지 주소 할당하기
    • mlx 윈도우 상 렌더링 준비

2022.08.11 (목)

mlx 윈도우 상 일회성 랜더링 구현완료

  • 남은 작업

    • fish eye 로 화면 왜곡되는 현상 수정
    • 동료와 작업 결합

2022.08.12 (금)

fish eye 로 화면 왜곡되는 현상 수정

동료와 작업 결합

키값에 대응하여 플레이어 위치와 시야 조정하는 기능 구현

키값이 눌릴 때마다 새로운 화면 생성 및 출력 기능 구현

  • 남은 작업

    • 플레이어가 벽을 통과하지 않도록 safe distance 유지하는 코드 구현
    • 사용한 자원들 해제하는 코드 구현
    • norminette 검수

2022.08.13 (토)

플레이어가 벽을 통과하지 않도록 safe distance 유지하는 코드 구현

사용한 자원들 해제하는 코드 구현

norminette 검수 완료

  • 남은 작업

    • 최종 검수
    • 평가 3회
    • 진행 기록 포스팅

2022.08.14 (일)

Makefile 제작

맵 검수 방식을 공백으로부터 8방향이 아닌 4방향으로 수정

  • 남은 작업

    • 최종 검수
    • 평가 3회
    • 진행 기록 포스팅

2022.08.14 (일)

주요 구현 아이디어

프로젝트 계획단계에서 만들었던 아이디어들이 구현시 어떻게 변형되어 적용되었는지,
그 외에도 구현하면서 마주친 난관, 그 해법 등을 소개합니다.

맵 파싱 및 오류 검증

파싱

char **map 에 맵 정보가 저장된다.
map[0] 부터 map[n] 까지 가로 1줄에 대한 정보(char *형)가 저장된다.
맵 안의 빈 공간은 0, 벽은 1, 플레이어 위치는 {N, W, S, E}, 맵 밖의 아무것도 없는 빈공간은 ‘ ‘(공백) 으로 저장한다.
input으로 들어온 라인에 ‘ ‘, ‘0’, ‘1’, {N, W, S, E} 가 아닌 문자가 있다면 Error 를 출력하고 종료한다.
map[0] 와 mapx 에 ‘ ‘으로 가득찬 문자열을 추가한다.
map[n][0] 와 map[n][k] (각 행 마지막 열) 에 ‘ ‘ 문자를 추가한다.
=> 맵은 ‘ ‘으로 둘러 쌓여있게된다. (검증 용이성을 위한 조치)
플레이어 위치를 뜻하는 문자, {N, W, S, E} 가 1개가 아닐 경우 Error 를 출력하고 종료한다.

이 결과 input 된 맵은 ‘ ‘ 으로 감싸여진 상태가 된다.

오류 검증

  1. map[n] 의 모든 ‘ ‘(공백)의 주변 4방에 대해 모든 문자가(존재한다면) ‘1’ 또는 ‘ ‘인지 검사한다.

    만약 아니라면 Error 를 출력하고 종료한다.

이렇게 하면 맵의 형식에 대한 오류 검증은 완료된다. (텍스처와 floor ceil 관련된 오류처리는 별도로 필요하다.)

player data

플레이어의 위치와 시선 방향으로 플레이어 데이터를 모두 표현가능하다.
(시야각에 대한 정보는 define 상수 처리해서 유동적으로 적용할 수 있게 하였다.)

플레이어는 정수로 표현된 좌표에 위치하지 않고, double 으로 표현된 좌표에 위치한다.

플레이어는 자신의 시야의 중심에 대한 방향값을 가지고 있다.(double 형, radian 단위)

	
typedef struct s_vector
{
	double	pos_x;
	double	pos_y;
	double	vision_theta;
}	t_vector;
	

플레이어 움직임

  1. 이동 방향 관련

    이동과 시선 회전에 대한 키가 입력될 경우 플레이어의 위치를 변경시켜준다. 초기엔 x축, y 축 에 평행한 정직한 방식으로 이동을 구현했지만, 플레이어가 바라보고 있는 방향을 기준으로 앞 뒤 좌 우 이동하는 것이 더 자연스러울 듯하여 해당 사항을 적용했다.

         
         if (keycode == KEY_W){
             game->map->player.pos_x += TYPE_MAN_PLAYER_POS * cos(game->map->player.vision_theta);
             game->map->player.pos_y += TYPE_MAN_PLAYER_POS * sin(game->map->player.vision_theta);
         }
         else if (keycode == KEY_S){
             game->map->player.pos_x -= TYPE_MAN_PLAYER_POS * cos(game->map->player.vision_theta);
             game->map->player.pos_y -= TYPE_MAN_PLAYER_POS * sin(game->map->player.vision_theta);
         }
         else if (keycode == KEY_A){
             game->map->player.pos_x -= TYPE_MAN_PLAYER_POS * sin(game->map->player.vision_theta);
             game->map->player.pos_y += TYPE_MAN_PLAYER_POS * cos(game->map->player.vision_theta);
         }
         else if (keycode == KEY_D){
             game->map->player.pos_x += TYPE_MAN_PLAYER_POS * sin(game->map->player.vision_theta);
             game->map->player.pos_y -= TYPE_MAN_PLAYER_POS * cos(game->map->player.vision_theta);
         }
         else if (keycode == KEY_LEFT)
             game->map->player.vision_theta += TYPE_MAN_PLAYER_ANGLE;
         else if (keycode == KEY_RIGHT)
             game->map->player.vision_theta -= TYPE_MAN_PLAYER_ANGLE;
         
     
  2. 벽과의 충돌 방지

    플레이어가 벽을 뚫고 들어가선 안된다. 벽과 동일한 위치에 위치해서도 안된다.
    즉 플레이어는 자신의 위치가 벽과 일정 간격을 유지하고 있는지 확인해야 한다.

    이 확인 작업은 이동 직후 이루어지도록 설계했다.
    사실 이동 전에 확인하고 이동하는 것이 최상이나 코드가 길어지게 되어 부득이하게 이동 직후 확인하도록 했다.
    이동 직후 확인할 경우의 단점은 벽과의 간격을 이동 거리보다 크게 유지해야 한다는 점이다.
    한 번에 1.0의 거리를 이동하지만, 0.2의 간격만 유지해도 된다고 할 경우, 그 경계를 훌쩍 넘고, 반대편으로 이동해버릴 수도 있다.

         
     int_x = (int)floor(m->player.pos_x);
     int_y = (int)floor(m->player.pos_y);
     exp_x = m->player.pos_x - int_x;
     exp_y = m->player.pos_y - int_y;
     if (m->map[int_y][int_x -1] == '1')
         if (exp_x < TYPE_SAFE_DISTANCE)
             m->player.pos_x = int_x + TYPE_SAFE_DISTANCE;
     if (m->map[int_y - 1][int_x] == '1')
         if (exp_y < TYPE_SAFE_DISTANCE)
             m->player.pos_y = int_y + TYPE_SAFE_DISTANCE;
     if (m->map[int_y][int_x + 1] == '1')
         if (exp_x > (1.0 - TYPE_SAFE_DISTANCE))
             m->player.pos_x = int_x + 1 - TYPE_SAFE_DISTANCE;
     if (m->map[int_y + 1][int_x] == '1')
         if (exp_y > (1.0 - TYPE_SAFE_DISTANCE))
             m->player.pos_y = int_y + 1 - TYPE_SAFE_DISTANCE;
     if (m->player.vision_theta < 0)
         m->player.vision_theta += 2 * TYPE_PI;
     if (m->player.vision_theta > 2 * TYPE_PI)
         m->player.vision_theta -= 2 * TYPE_PI;
         
     

Ray Casting

Wolfenstein 3D처럼 레이케스팅을 구현하는 방법은 다음과 같다.
Wolfenstein 3D가 렌더링 되는 방법에 대한 기초적인 이해가 선행되어야 한다.
아래 포스팅을 참고하면 충분할 듯하다.

과제 기초 개념
https://kirkim.github.io/42seoul/2021/04/15/cub3d1.html

input

  • map, player position(표기: {x, y}, 이하 p_pos)
  • player sight direction(표기: $\theta$ (radian) , 이하 p_dir)
  • player viewing angle (표기: a (radian), 이하 angle)
  • screen size (가로 크기: \$(VER), 세로 크기: \$(HOR) (단위: px))
  • obstacle pixel (오브젝트의 한 면이 차지할 픽셀, 가로와 세로가 동일한 정사각형을 가정. (단위: px))

output

  • \$(VER) * \$(HOR) size screen
  1. p_pos(플레이어의 위치)에서 p_dir 을 중심으로 angle 만큼의 시야를 펼친다. 그리고 아래 그림과 같이 1px 단위로 시야각을 나눈다.
    이렇게 나뉘어진 screen 가로 픽셀수 만큼의 벡터들을 ray라고 부르겠다.

    1. 시야각을 화면 크기에 맞게 ray들로 분할
  2. 두번째로는 1px 간격으로 생성된 ray 들의 각도를 구한다.

    2. ray들의 각도 구하기
  3. p_pos 에서부터 ray의 방향으로 쭉 뻗어나가며 다음을 수행한다.

    1. 자신이 screen 에서 몇번째 픽셀에 대응되는 ray인지 알아야 한다.
    2. 처음 마주치는 장애물과의 distance 를 구하고 <= 렌더링시 벽이 screen 에서 차지할 vertical size를 산출할 때 사용
    3. 장애물의 어느쪽 벽(NWSE) 와 부딛혔는지 확인하고 <= 렌더링할 대상 이미지가 무엇인지 확인
    4. 장애물의 벽에서도 (왼쪽부터)몇번째 픽셀에 부딛혔는지 확인한다. <= 렌더링시 벽의 몇번째 가로줄을 렌더링할 지 정하는 기준.
    3. ray_casting 렌더링 하기 위해 필요한 4가지 값들을 구하기

    obstacle 은 장애물을 위에서 내려다본 모양이다. obstacle 은 4방향이 벽으로 이루어져 있고 그림에서 ray 는 obstacle 의 남쪽 벽과 부딛힌 상황이다.

    여기서 조심할 점은 player의 위치와 ray 충돌점과의 거리를 distance 로 상정하고 렌더링 할 경우 어안렌즈효과가 나타난다.
    (처음에 이런 방식으로 했다가 이미지가 왜곡되어 나타나서 원인을 찾고 바로잡는데 고생좀 했다…)

    어안렌즈 효과가 나타났던 초기 결과물
    fish eye effect
  4. 3에서 준비된 \$(HOR) 픽셀 갯수 만큼의 ray 데이터를 이용해 이미지를 screen에 렌더링한다.

    1. distance 가 아래 그림에 나온 a와 동일할 경우 해당 ray 가 부딛힌 object가 screen 에서 차지할 수 있는 세로 픽셀수는 object pixel(input 으로 받았던 값이다.) 과 같다. distance 가 클수록 screen 에 표현될 세로 픽셀 수는 작아진다. (object_pixel: screen_pixel = distance : a)
      위 산식을 이용해 object가 스크린 상에 표현될 세로 픽셀 크기인 screen_pixel 을 구할 수 있다. screen_pixel 이 \$(VER) 보다 클 경우 초과분은 렌더링 하지 않는다.

    2. screen_pixel 을 구했다면, 그 비율에 맞게 렌더링해야 할 이미지의 가로 한줄을 ray가 위치한 screen의 가로 번지수에 배치하고, 만약 screen_pixel이 \$(VER)보다 작다면, 남는 위와 아래는 ceil argb값과 Floor argb값으로 채워주면 된다.

    4.render

    사실 이 부분을 구현하는 것이 정말 까다롭다. 저 비율만큼 wall의 sprite image 를 확대또는 축소하여 화면에 대응시켜야 하는데 이 복잡한 수식을 말로 풀어낼 자신이 없다… 내가 짠 코드라고 첨부할까 생각했는데 가독성이 구두 설명없이는 이해하기 어려울 정도로 낮아 망설이다가 포기했다.
    (chanhale로 slack DM 남겨 주시면 어떻게든 설명 드리겠습니다)

이해를 돕기 위한 추가적인 첨부 그림

어안렌즈가 나타나는 오류 관련
어안렌즈가 나타난 화면 (시야가 넓어지고, 화면 중앙은 더 가까워 보이고, 가장자리는 실제보다 멀어보인다.)
정상적인 수행
정상적인 화면

프로젝트 소감

math.h 라이브러리를 제한 없이 사용할 수 있도록 허용해줘서 삼각함수관련된 함수들을 원없이 사용할 수 있었다. 사전 준비도 parsing 과 좌표 할당관련되어 준비했었는데 동료와 파트를 나누는 과정에서 어쩌다보니 Ray Casting 부분을 맡게 되었다. 사전 준비에서 구상한 아이디어들을 동료에게 넘겨주고, 새로운 아이디어를 구상했어야 했는데 이게 또 나름 재미있었다.
아이패드 들고다니면서 렌더링 을 어떻게 구현할지 개념을 생각해보고, 그것을 실현할 수식들을 설계하고, 그것을 코드로 구현하는 것 까지…
처음엔 수식들에서 오류들이 나와서 출력이 이상하게 되었지만, 다시 수식 설계하고, 코드에 반영하고, 결국 어안렌즈처럼 왜곡된 결과였지만, 어떤 형태를 갖춘 것이 렌더링 되었을 때! 정말 기뻤다. 이 맛에 코딩하지 싶었다. 새삼 이걸 직업으로 가질 수 있다는게 다행이다는 생각도 든다.
어안렌즈 오류를 해결하기 위해 연산오류가 있었는지, 부동소숫점의 정수화 과정에서의 underflow 인지 여러가지 의심해보며 고민하던 끝에 screen에 담아야 할것이 어떤 한 점에서 관측된 결과를 담는게 아니라 단지 창의 역할으로 보여주기만 하면된다는 것을 께닫게 되었을 때, 그리고 그것을 반영하는 코드를 짜면서 과연 이게 내가 찾던 해법인가? 두근거리면서 실행하고 왜곡되지 않은 결과물을 확인했을 때, 암튼 꽤나 머리아픈 수식 설계가 있었지만, 재미있는 파트를 담당해서 즐거웠다.

(제출 이후)오류 책임 공동분담
지난번 팀플에서 아쉬웠던 점이 제출버튼을 누르는 것에 동의한 순간 오류가 나더라도 그 책임이 그것을 구현한 개인이 아닌 팀 전체의 것이 된다는 것을 약속하지 않았던 것인데 (사실 당연한 부분인데 명시적으로 약속을 안하면 그 파트를 맡은 사람 입장에선 멘탈이 심각하게 흔들린다.) 이번 팀플에선 그 약속을 제출 전에 해서 제출 후에 사소한 오류가 있었음에도 서로 어떤 불편함이나 무안함이 상대적으로 덜할 수 있었다.
나름 발전을 이루어 뿌듯한 부분이다.

팀원과 소통은 아직 살짝 아쉽다.
JIRA 같은 협업툴을 이번에 이용해볼까 하다가 JIRA 사용법을 익힌다고 프로젝트 일정이 밀릴까봐 포기했었다. 지난 팀플은 1주일에 1번을 만났다면 이번 팀플은 이틀에 한번은 대면으로 만나서 진행을 공유하고, 필요할 경우 줌을 활용해 화상회의도 했지만, 아직 뭔가 ‘정말 긴밀하다’에는 미치지 못한듯 하다. 다음 팀플에서 좀 더 개선해봐야겠다.

Comment

There are currently no comments on this article
Please be the first to add one below :)

Leave a Comment

내용에 대한 의견, 블로그 UI 개선, 잡담까지 모든 종류의 댓글 환영합니다!!