Lode's Computer Graphics Tutorial: Raycasting에 대한 조금 긴 사설

Yeseul Ganga Han·2022년 12월 2일
0
👉 우리 프로그램 실행 화면!

한 달 간 팀원과 함께 레이캐스팅을 이용해 미로를 3D그래픽으로 구현하는 프로그램을 만들었다.

Lode's Computer Graphics Tutorial: Raycasting(이하 '튜토리얼')과 이 튜토리얼을 풀어 쓴 42seoul 카뎃의 블로그를 적극적으로 참고했다. 특히 블로그의 설명이 정말 친절해 많은 의지가 되었지만 우리에게는 여전히 어렵게 느껴지는 부분이 많았는데, 튜토리얼을 보고 역으로 공부해 나가는 방법은 이 과제에서 할 수 있었던 경험 중 하나였다. 해결방법 중심의 튜토리얼을 사이에 두고 어떻게 이 식이 도출된 것인지, 이 방법은 꼭 이렇게 해야만 하는 건지 팀원과 토론하며 이해해 나가는 시간이 참 즐거웠다.

이 글에서는 그 과정에서 얻게 된 통찰과 튜토리얼 코드의 개선지점 등을 담았다. 왜 수직거리를 구해야만 할까? 우리는 0으로 나누는 연산을 막지 않아도 될까? deltaDist, texX 등의 변수를 구하는 복잡한 도출과정과 함축적인 식을 어떻게 하면 보다 알기 쉽게 표현할까? 막힌 벽인지를 결정하는 코드는 과연 모든 경우를 커버할 수 있을까? 다음은 이런 질문에 대한 우리의 대답이다. 튜토리얼에서 사용된 주요 변수를 목차 삼아 내용을 정리해보았다. 물론 몇 가지는 나만의 질문과 대답이다. 부족한 것이 있다면 아마도 그것..


👉 레이캐스팅의 4단계

screenWidth

레이캐스팅의 전체 작업은 결국 화면을 구성하고 있는 픽셀들에 각각 어떤 값을 줄지를 결정하는 과정이다. 내가 사용하는 모니터는 가로 1560, 세로 1440개, 총 2246400개의 픽셀로 화면을 표현하고 있다. 우리는 각 픽셀에 setPixel()을 사용해 일일이 색을 넣어 주었는데, 당연히 스크린의 모든 픽셀은 값을 가져야 한다. 이 때 1560, 1440이 담기는 변수가 screenWidth, screenHeight다.

레이캐스팅은 벽의 좌표를 얻기 위해 광선을 쏜다. 이때 광선의 수screenWidth와 동일하다. 이 점이 레이트레이싱과의 큰 차이점이다. 추적할 광선의 수가 적은 덕분에 레이캐스팅에서는 렌더링 프로세스의 속도가 높다. 사람의 눈은 약 2천5백 * 2 = 5천만 픽셀의 세상을 본다고 하니, 레이캐스팅으로 따지면 2천5백 개의 광선을 쏘아 물체와의 거리를 재고 있는 셈이다. 하지만 스크린은 어차피 가로 screenWidth만큼의 픽셀만 표현할 수 있으므로, screenWidth개의 광선을 쏘아 거리정보를 얻으면 된다.

dir, plane

👉 사람의 실제 시야보다 좁은 시야만 모니터에 표현한다

벡터 plane과 플레이어의 시선을 나타내는 벡터 dir의 비율은 플레이어의 시야각을 결정하는 데 사용된다. 이때 dir : plane은 일반적으로 1 : 0.66이라고 하는데 처음에는 플레이어의 시야와 인간의 실제 시야와의 관계성을 파악하지 못해 미궁에 빠졌었다. 플레이어 시야가 우리의 실제 시야보다 훨씬 좁은 이유는 모니터가 우리 시야의 아주 적은 부분만을 차지하고 있기 때문이다. 1 : 0.66모니터와 사용자의 거리 : (모니터의 가로길이 / 2)의 비율과도 비슷하다.


👉 plane과 dir은 시야’각’을 결정하는 데만 사용한다. 그러므로 둘은 정확히 같은 경우다.

plane이 그리는 직선은 단순하게 말하면 모니터 평면이다. 하지만 완전히 치환될 수는 없는데, 어떤 '각도'의 광선을 선택하는 데에 사용될 뿐, 모니터와 실제 벽까지의 거리(perpWallDist)를 계산할 때는 사용되지 않기 때문이다. 그래서 그림에서 확인할 수 있듯이 plane이 그리는 직선이 플레이어와 얼마나 떨어져 있는지는 중요하지 않다. 다시 말해 dir의 길이에 따라 plane의 길이가 조절되기만 한다면(=두 벡터의 길이 비가 일정하다면) 두 벡터의 길이 자체는 중요하지 않은 것이다. 다만 한 쪽을 고정하고 다른 한 쪽의 길이를 증감하면서 비율을 조절하면 편하기 때문에 우리는 dir의 길이를 1로 고정했다.

plane, cameraX

👉 cameraX는 screen의 x번째 픽셀이 plane의 어디인지 알려준다. 광선은 그 지점을 지나도록 각도가 설정된다.

이제 광선은 plane이 그리는 직선 위를 지나도록 제한되는데, 그 중에서도 정확히 어떤 점을 지날지 결정할 때 사용하는 값이 cameraX다. plane이 1일 때 어떤 지점인지를 나타내는 비율값이므로 plane에 곱해서 사용하게 된다. 그러면 screenWidthplane만큼 줄였을 때 스크린에서의 x좌표가 plane에서는 어디에 해당하는지 알 수 있다. 광선은 그 지점을 지나게 된다. 이런 식으로 (plane * cameraX)가 -1인 점부터 1인 점까지 screenWidth개 만큼의 광선을 쏘게 된다.
광선과 광선의 간격이 일정한 각도를 가지지 않음에 유의하자. 맵의 풍경은 오목한 모니터가 아니라 평평한 모니터에 표현될 것이므로, 풍경의 정보도 평평한 모니터 픽셀의 간격에 따라(=수직선 상에서 일정하게 증가하는 좌표에 따라) 가져와야 한다. 만약 나를 빙 둘러싼 모양의 모니터였다면 ‘각도’를 일정하게 증가시켰겠지만 말이다.

mapX, mapY, posX, posY

벽은 mapXmapY값 즉 (2, 4) 같은 점으로 저장되긴 하지만 점의 공간만 차지하는 것은 아님을 기억해야 한다. 2차원 지도에서는 해당 좌표를 왼쪽 위 모서리로 가지는 그리드 한 칸을 의미하고, 레이캐스팅으로 구현하는 3차원 공간에서는 하나의 정육면체를 의미한다.


👉 perpWallDist를 계산할 때, 광선의 방향이 음일 경우 mapX, mapY의 값을 보정해주어야 한다

이런 특징으로 인해 mapXmapY은 종종 1을 더하여 사용된다. 정육면체의 마주보는 벽이 1만큼의 거리를 가지고 있고, 필요한 벽면이 어느 쪽인지에 따라 사용되는 값도 달라져야 하기 때문이다.

mapX, mapY가 int로 선언되어 벽을 탐색하는 동안 좌표를 저장하는 데 사용된다면, double로 선언되는 posXposY는 플레이어의 위치좌표다. posXposY는 처음에는 2차원 지도에서 플레이어의 좌표값(즉 정수)으로 초기화되지만, 이후에는 mapXmapY와 달리 실수연산을 통해 부드럽게 이동하게 된다.

sideDistX, sideDistY, deltaDistX, deltaDistY

sideDistXsideDistY, deltaDistXdeltaDistY는 DDA라는 선긋기 알고리즘에 사용되는 변수다. 벽을 찾기 위해 플레이어의 위치부터 광선이 지나는 좌표들을 mapX, mapY에 담아 확인하는데, 이 다음에 mapX, mapY에 담을 좌표를 결정할 때마다 sideDistXsideDistY를 비교하게 된다. sideDistX, sideDistY는 플레이어와 가장 가까운 벽면까지의 거리로 초기화하고, 부딪힌 벽은 deltaDistX 혹은 deltaDistY를 더해 값을 갱신한다. 일반적인 경우 while 루프 안에서 sideDistXsideDistY가 번갈아가며 선택된다.

처음에는 이름에 ‘거리(distance)’가 들어 있어서 헷갈렸는데, sideDistX, sideDistY, deltaDistX, deltaDistY은 실제 거리값을 담고 있지 않다. 이 변수들은 어디까지나 mapX, mapY 중 어느 값에 1을 더해줄 지를 선택하기 위해 잠시 사용할 뿐, 이후 벽과의 거리를 계산할 때는 필요 없기 때문이다. sideDistXsideDistY, deltaDistXdeltaDistY의 비율만 중요하다는 말은 그런 의미다.


👉 직각삼각형 닮음을 이용하여 sideDistX 구하기

튜토리얼에서 deltaDistsideDist은 각각 피타고라스정리, 직각삼각형 닮음을 이용해 식을 유도하고 있는데, 조금 복잡해 보인다. 핵심은 deltaDistrayDirX, rayDirY의 비율을 이용하여 구하고, sideDistdeltaDist를 단위거리로 이용하여 구한다는 것이다.

단, 튜토리얼의 deltaDist의 식은 지나치게 간소화되었다고 판단해, 피타고라스정리에서 유도된 식임을 알아볼 수 있도록 다음과 같이 바꿔보았다.

deltaDistX = sqrt(1 + pow(rayDirY / rayDirX, 2));
deltaDistY = sqrt(1 + pow(rayDirX / rayDirY, 2));
👉 피타고라스 정리를 이용하여 deltaDistX 식 유도하기 (보다 덜 축약한 버전)

또, 튜토리얼의 경우 rayDirX 혹은 rayDirY가 0일 때 (즉 0으로 나누기 연산이 있을 때) 일부 언어에서 문제가 됨을 언급하며

double deltaDistX = (rayDirX == 0) ? 1e30 : std::abs(1 / rayDirX);
double deltaDistY = (rayDirY == 0) ? 1e30 : std::abs(1 / rayDirY);

이렇게 따로 처리를 해주었다. 우리는 처음에 C언어는 0으로 나누기를 지원해주지 않는다는 이상한 확신을 가지고, 따로 처리 해주지 않았는 데도 잘 돌아가는 동료의 프로그램을 터트려 보려고 애썼었다. 그런데 알고 보니 동료의 코드는 문제되는 코드가 아니었다.

우선 C언어에서 0으로 나누기는 int일 경우 런타임에러가 나는 문제가 있으나, double 등의 부동소수점 자료형인 경우 결과를 infinity로 준다는 점을 간과했다. 그리고 무엇보다 이 infinity 값을 이용해도 우리가 원하는 결과를 낼 수 있다. 0으로 나눠졌다는 것은 광선이 그 방향으로는 전혀 이동하지 않는다는 의미이고, 결과 값이 infinity라면 sideDist를 비교할 때 항상 선택되지 않을 것이기 때문이다. infinity는 모든 수보다 더 큰 수이니까!

perpWallDist

👉 삼각형 닮음을 이용하여 y축에 수직인 벽면까지의 수직거리 구하기

수직거리perpWallDist는 레이캐스팅의 핵심적인 값으로, 이제 이 값에 반비례한 크기로 화면에 표현하면 끝이다. perpWallDist를 구하는 단계 전까지의 작업은 모두 벽의 좌표를 찾기 위한 과정이었을 뿐, perpWallDist를 계산할 때는 앞에서 구한 값들 중 아무 것도 사용하지 않으니 신경쓰지 말자.

perpWallDist는 정확히는 plane이 그리는 직선과 평행하고 플레이어의 위치좌표 posXposY를 지나는 가상의 선에, 부딪힌 벽의 지점에서 수선의 발을 내려 구하게 된다. 왜 광선의 길이를 그대로 사용하지 않고 수직거리를 구해야 하는지 정확한 이유를 찾기 위해 정말 넓은 시야, 정말 긴 모니터, 오목한 모니터처럼 여러 경우를 가정해보면서 생각해 보았다. ‘수직거리를 구함으로써 어안렌즈 효과를 방지한다’는 설명에 꽂혀 물고기 눈과 인간 눈의 차이까지 살펴보려고 했지만 그 문제는 아니었고…

perpWallDist를 '광선거리를 보정한다'는 식으로 많이 이해하곤 하는데, 광선거리를 고려할 필요 없이 바로 '수직거리를 구한다'고 이해하는 편이 더 수월하고 논리적으로도 맞는 것 같다. 엄밀히 말하면 광선은 화면에 표현할 벽을 찾기 위한 것일 뿐, 일단 벽을 찾고 나면 플레이어의 위치는 지워지고 벽과 모니터평면의 거리(=수직거리)만 중요해지기 때문이다. 플레이어가 본 것을 우리의 모니터평면에 표현하기 위한 과정이다. 맨 위의 '레이캐스팅의 4단계' 그림을 참고하자.

첫 번째 결론은 우리가 이미지를 나타내려는 곳은 모니터 평면이고, 모니터를 창이라고 생각했을 때 창과 창 밖 물체와의 거리만을 반영하여 표현하면 된다는 것이다. 창과 사람의 거리는, 이후에 사람이 창을 바라보면서 자연스럽게 반영될 것이다. 그러므로 아마 물고기를 위한 모니터라고 해서 계산이 다르지 않을 것이다!

👉 개선 전의 perpWallDist는 플레이어의 시야가 실제 모니터가 내 시야에서 차지하는 비율과 비슷한 상황을 가정하고 있어, 정말 넓은 시야를 그대로 표현하지 못하는 한계를 지닌다.

두 번째 결론은 우리가 사용한 무한한 가상의 선은 특정한 경우(즉 플레이어의 시야가 실제 나의 시야에서 모니터가 차지하는 비율만큼으로 설정됐을 경우)에서만 가능하다는 것이다. 실제 인간의 시야를 그대로 표현하고자 한다면 직선의 담벽도 끝으로 갈 수록 원근법에 의해 조금씩 작게 그려야 하는데, 무한한 길이를 갖는 가상의 선을 모니터평면으로 삼아 거리를 계산하면 이런 표현이 불가능하다. 다시 말해 지금의 perpWallDist를 사용하면 y축에만 원근법을 적용한 셈이 된다. 개선된 perpWallDist의 계산은 일정한 길이의 모니터평면을 설정하여, 시야가 넓어질 경우 좌우로 먼 시야에 대해 원근법을 적용하여 표현한다. 과제에 적용해보진 못했다. 구현할 경우 무조건 수선의 발을 내리는 것이 아니므로 모니터평면의 어떤 지점과의 거리를 잴 것인지에 대한 계산이 추가되어야 할 것 같다.

wallX, texX

레이캐스팅 작업의 기본 단위는 결국 픽셀이다. 픽셀은 디지털 이미지의 최소 단위로, 우리가 보는 화면의 한 색깔 조각이자 하나의 색 정보를 담고 있는 (우리의 경우 int) 값이다. 누군가에게는 당연하게 들릴지도 모르겠지만 이걸 깨닫고 나니 xpm 파일, mlx 이미지, 텍스쳐, 스크린 등 난무하는 픽셀뭉치(?)들 사이에서 헤매던 생각이 정리가 되었다. 특히 화면 그리기와 관련된 작업에 사용하는 변수 drawHeight, drawStartdrawEnd, texX, screenWidthscreenHeight 등이 모두 픽셀이라는 공통된 단위를 사용한다는 사실을 기억하자.


👉 텍스쳐에서 픽셀을 가져오는 방식. texX와 texPos.

그림과 같이, 만약 스크린에 5 * 5개 픽셀로 벽을 그리기로 결정했는데 텍스쳐의 크기가 10 * 10이라면 일정한 간격으로 걸러가며 픽셀을 가져와야 할 것이다. 이 작업에서, 가져올 텍스쳐 픽셀의 x좌표를 결정하는 것이 texX, y좌표를 결정하는 것이 (뒤에서 설명할) texY(texPos)다.


👉 mapX, wallX, texX의 관계

wallXtexX를 구하기 위해 잠시 사용되는 변수인데, 보정되는 과정이 이해를 방해하므로 우리는 임의로 mapXwallX로 나누어 사용하기로 했다. 먼저 2차원 지도 상에서 광선이 닿은 정확한 지점을 구하고(mapX), 그 지점이 1 * 1 크기의 벽에서는 어느 지점에 해당하는지를 계산한 뒤(wallX), 다시 그 지점이 특정 크기의 텍스쳐에서는 몇 번째 픽셀에 해당하는지를 구하면 된다(texX). 실제 C코드에서는 map_offset_x, wall_offset_x, texture_offset_x로 변수명을 지었는데 이처럼 오프셋으로 이해해도 좋겠다.


👉 y축에 수직인 면에 충돌했을 때 wallX와 texX

다만 texX의 값은 보정이 필요하다. 이 값은 태생이 좌표 상의 어떤 지점인데, 좌표의 증가방향은 (x축의 경우) 언제나 서쪽→동쪽이기 때문에, 이 오프셋을 그대로 활용할 경우 텍스쳐는 언제나 남쪽을 향해 세워진다. 문제는 우리 플레이어는 이 벽을 남쪽에서만 보는 것이 아니라는 것이다. 보정이 없을 경우 북쪽에서 벽을 바라보는 플레이어는 좌우가 반전되어 세워진 텍스쳐를 보게 된다.


👉 x축 혹은 y축의 증가방향과, 텍스쳐의 왼쪽→오른쪽의 방향이 다르면 texX는 좌우반전의 결과를 보여주므로 보정이 필요하다.

텍스쳐의 오프셋은 플레이어의 왼쪽→오른쪽에 맞추어 증가해야 자연스럽다. 그러기 위해서 축의 증가방향과 플레이어의 왼쪽→오른쪽 증가방향이 서로 다른 서쪽 벽면남쪽 벽면, 두 케이스에 대해 texX를 보정해준다.

단 보정하는 케이스를 정하는 if 조건문에서 튜토리얼의 이 코드를 우리 프로그램에 사용했을 때,

//x coordinate on the texture
int texX = int(wallX * double(texWidth));
if(side == 0 && rayDirX > 0) texX = texWidth - texX - 1;
if(side == 1 && rayDirY < 0) texX = texWidth - texX - 1;

모든 텍스쳐를 좌우로 반전시키는(!) 잘못된 결과를 냈다. github에서 clone해 본 다른 코드에서도 같은 실수를 한 것으로 보아, 대부분 좌우가 대칭된 텍스쳐를 사용하기 때문에 오류를 쉽게 알아차리지 못하는 듯하다. 우리는 좌우를 구분할 수 있는 텍스쳐를 사용하여 보정 여부를 테스트했다.

texY(texPos)

texYtexX와 쌍을 이루는 값으로, 텍스쳐의 픽셀 중 y좌표의 texY번째 픽셀을 가져오게 된다. 일반적으로는 0이지만, 플레이어와 벽의 거리가 너무 가까워서 화면을 넘어서는 크기로 표현되어야 할 때는 1 이상의 값을 가진다. 그러면 텍스쳐의 윗부분(과 아랫부분)이 잘린 채로 화면에 꽉 차게 표현된다.

// Starting texture coordinate
double texPos = (drawStart - h / 2 + lineHeight / 2) * step;
👉 튜토리얼의 texPos를 구하는 식을 풀어 써 보았다.

texY를 초기화하기 위한 texPos를 구하는 튜토리얼의 이 식은, 변수 drawStart의 값과 그 값을 구하는 데 사용했던 식을 응용한다. drawStart라는 변수에는 이미 해당 값이 담겨 있는데, 벽이 너무 커서 스크린을 넘었을 경우(즉 음수일 경우)에 한해 0으로 보정해 두었음을 기억하자. 이때 보정된 drawStart보정 전 drawStart의 차를 이용하면, 텍스쳐가 화면 안에 있을 때(drawStart의 보정 전후가 같을 때, 즉 texPos == 0)와 텍스쳐가 화면 밖으로 넘어갔을 때(drawStart의 보정 전후가 다를 때, 즉 texPos == |drawStart| * step)의 texPos를 하나의 식으로 구할 수 있다.


👉 texPos구하는 식을 알기 쉽게 변형해 보았다.

우리는 이 식이 너무 축약되어 가독성이 떨어진다고 생각해서 (실제로 우리가 이해하는 데에 너무 오래 걸렸기 때문에..) if/else문을 사용해서 texPos를 구하도록 수정했다. 이를 위해 drawStart가 음수일 경우에 필요한 값의 보정은 texPos를 구한 후에 해 주었다.

keyDown(SDLK_UP), keyDown(SDLK_DOWN)

지금 보면 간단하지만 꽤 골치를 썩혔던 부분이다. 튜토리얼의 이동방식은 다음과 같이 posXpoxY를 각각 갱신해주었는데,

//move forward if no wall in front of you
if (keyDown(SDLK_UP))
{
  if(worldMap[int(posX + dirX * moveSpeed)][int(posY)] == false)
		posX += dirX * moveSpeed;
  if(worldMap[int(posX)][int(posY + dirY * moveSpeed)] == false)
		posY += dirY * moveSpeed;
}

이렇게 하면 벽면에 비스듬히 부딪혔을 때 미끄러지듯 움직이도록 표현하는 것이 가능하지만, 최종 갱신된 좌표가 벽일 경우를 걸러내지 못하는 문제가 생긴다.


👉 튜토리얼의 이동방법 경우의 수 8가지. case2는 커버하지 못하므로 추가 작업이 필요하다.

세 좌표 worldMap[int(posX + dirX * moveSpeed)][int(posY)], worldMap[int(posX)][int(posY + dirY * moveSpeed)], worldMap[int(posX + dirX * moveSpeed)][int(posY + dirY * moveSpeed)]가 모두 다른 벽을 확인하는 경우만 고려했을 때 경우의 수는 총 8가지다. 그 중 특히 고려해 보아야 할 5개 케이스를 위쪽에 적어 보았다. 이 중 벽면에 미끄러지듯 움직이는 것이 case 3-1와 case 3-2, 문제가 된 것은 case 2의 경우다.

case 2를 해결하는 가장 간단한 방법은 posXposY를 모두 갱신하지 않는 것이지만, 우리는 case 2도 case 3-1와 case 3-2처럼 posXposY 중 가능한 하나를 선택해 갱신하도록 하고 싶었다.

처음에는 case 2를 포함해 최종 갱신하려는 위치가 벽인 경우는 무조건 posXposY 중 하나를 선택하도록 했는데, 이렇게 하니 이동하면 안 되는 case 1에서도 이동해버리는 것이 문제였다. case 2에서 아무 벽면이나 선택하므로 자연스러워 보이지도 않았다. 그 다음에는 case 2의 경우에 한해서 갱신된 점이 벽의 어느 부분에 위치하느냐에 따라 posXposY 중 하나를 선택하도록 했는데 이 역시 모든 경우를 커버하지 못함을 알게 되었고 moveSpeed가 높아질 수록 정확도는 더 떨어지는 문제가 있었다.

최종적으로는 DDA 알고리즘을 활용하여 문제를 해결했다. case 2만 봤을 때 고려해야 하는 벽은 언제나 1개이고, 플레이어가 벽의 모서리 근처를 향해 전진하려고 할 때 플레이어의 시선 정 가운데에 해당하는 벽면을 선택해 미끄러지면 자연스럽다. 즉 플레이어의 시선인 벡터 dir이 맨 처음 부딪힌 벽이 y축에 수직인 면이라면 posX를 선택, x축에 수직인 면이라면 posY를 선택하여 갱신하면 되는 것이다! 기존에 광선에 닿는 벽을 찾는 알고리즘을 dir에 대한 것으로 바꾸면 dir이 부딪힌 벽면을 확인할 수 있다.

0개의 댓글