포탈을 아시나요?
게임에 관심이 있는 사람이라면 2007년 Valve가 출시한 게임 ‘포탈’을 한 번쯤 들어봤을 것이다. 포탈건이라는 장비를 사용해 두 공간을 연결하는 포탈을 만들고, 이를 활용해 각종 퍼즐을 풀어나가는 게임이다.
나는 초등학생 때 처음 이 게임을 접했는데, 그때 받은 신선한 충격이 아직도 남아있다.
포탈을 통해 연결된 공간 너머를 볼 수 있고 사물이 통과할 수도 있다. 너무 신기하지 않은가? 나중에 컴퓨터를 배우면 이런 거 한 번 만들어보자 다짐을 했었는데 많은 세월이 지나 그래픽스를 배우고 나서야 도전을 해보게 되었다.
학부 때 조금 공부해 봤던 OpenGL 환경에서 진행했는데 아무래도 공부한 지 1년 반은 넘어서인지, 개념이 흐릿해진 부분이 많았다. 맨땅에 헤딩 식 마인드를 갖추고 열심히 진행한 결과 완성까지 대략 2주 정도 걸린 것 같다.
환경
- OpenGL 3.3
- LWJGL
- JOML
환경에 대해 잠깐 얘기해 보자면, 원래는 학부 시절에 실습해 봤던 환경과 똑같이 구성하려 했다. 그런데 자료를 찾다 보니 내가 예전에 했던 방식은 레거시였고 이는 더 이상 권장되지 않는 방법이었다.
물론 레거시도 포탈 효과를 구현하기에 충분한 환경이었을 것이라 생각한다. 왜냐하면 현대 방식인 3.0 버전이 2008년에 나왔는데 포탈은 이미 2005년에 베타 버전을 출시했었기 때문이다. 어쨌든 할 거면 권장 버전을 쓰는 게 낫겠다 싶어 OpenGL은 3.3을 골랐다.
그다음은 LWJGL을 선택한 이유이다. 이걸 고른 이유는 그냥 IntelliJ의 강력한 기능을 쓰면서 개발을 하고 싶었는데, 마침 조금 찾아보니 자바로도 OpenGL 개발이 가능하다길래 냅다 해보기로 했다. Gradle을 덕분에 의존성 관리도 쉬웠고 맥북에서 별다른 세팅 없이 IntelliJ를 깔고 바로 실행 가능해서 굉장히 편리했다.
JOML은 수학 라이브러리다. LWJGL와 함께 자주 활용되는 듯해서 골랐다.
구현 과정
기초 환경
렌더링 파이프라인 이론적인 부분은 나름 공부를 했었는데도 OpenGL 3.3에 나오는 개념은 완전 처음이라 어려움이 많았다. VAO, VBO 개념이 바로 이해가 안 가서 삼각형 하나 그리는 데에도 애를 많이 먹었다.
[Introduction | 3D Game Development with LWJGL 3
Introduction This online book will introduce the main concepts required to write a 3D game using the LWJGL 3 library. LWJGL is a Java library that provides access to native APIs used in the development of graphics (OpenGL), audio (OpenAL) and parallel comp
ahbejarano.gitbook.io](https://ahbejarano.gitbook.io/lwjglgamedev)
그래서 이 프로젝트의 초반부는 거의 위 튜토리얼을 보며 따라 해보는 게 전부였다. 정확히 8편까지 진행하면 어느 정도 환경이 갖춰지는데 여기까지 일주일 정도 걸린 것 같다. 그리고 어느 정도 준비가 된 듯싶어 카메라가 움직일 수 있도록 코드를 약간 손 본 다음 바로 포탈 효과를 만들기에 돌입했다.
포탈 구현을 위한 이론 정리
일단 포탈을 구현하기로 마음먹었다면, 가장 먼저 기본적인 이론을 알아야 할 것이다. 이와 관련해서는 유튜브에 검색하면 자료가 엄청나게 나온다. 여기서는 직관을 부여할 정도로만 간단히 다루겠다.
대부분 어렸을 적에 짱구를 본 적 있을 것이다. 짱구 와르르맨션 편을 보면 짱구와 오수가 벽에 구멍을 뚫는 장면이 있는데, 나는 이 장면을 예로 들어 포탈 설명을 해보겠다.
오수의 분노가 담긴 펀치로 구멍이 생성되었다. 지금부터 이 구멍을 포탈이라고 생각하자.
그림처럼 포탈이 벽 하나를 두고 양쪽에 생성된 상황으로 볼 수 있다.
여기서 오수의 눈을 카메라라고 가정해 보겠다. 검은색 점선은 오수에게 보이는 시야 범위를 나타내고 주황색 실선은 오수가 구멍을 통해 볼 수 있는 시야 범위를 나타낸다.
원래는 구멍이 없었으니 오수의 눈에는 책상과 초록색 벽만 보였을 것이다. 하지만 구멍이 생겼기 때문에 이제 초록색 벽에 구멍의 영역만큼 짱구네 집 안이 보이게 되었다. 여기서 중요한 건 구멍이 오수의 시야에서 차지하는 영역만큼 건너편이 보이게 되었다는 것이다.
거듭 강조하지만 핵심은 구멍이 오수의 시야에서 차지하는 영역이다. 저 영역만큼 건너편 공간이 보여야 한다. 오수의 눈이 메인카메라인 경우 렌더링 과정을 생각해 본다면, 우선 책상과 벽이 그려지고 벽에 생긴 저 구멍의 크기만큼 건너편 공간의 오브젝트들 또한 그려져야 할 것이다.
포탈의 특성을 설명하기 위해 지금부터 조금 억지로 상황을 하나 부여하겠다. 갑자기 와르르맨션에 지층변화가 발생해서 오수네 집 밑의 땅이 솟아올라 버렸다. 바로 다음과 같이 말이다.
땅이 솟아올라 버린 탓에 공간이 분리되어 버렸다. 하지만 저 구멍은 포탈이고 짱구네 집과 연결되어 있으므로 오수가 포탈을 통해 볼 수 있는 장면은 이전과 완전히 똑같다. 이는 포탈이 두 공간을 연결한다는 특성에서 기인하며 오수와 포탈, 그리고 짱구와 포탈이 각각 상대적 위치, 회전을 달리하지 않는 한 포탈이 보여주는 장면은 절대 달라지지 않는다는 것을 말해준다.
포탈에 어떤 장면이 그려져야 하는지 알았으니 이제 그 장면을 어떻게 가져올지 생각해야 한다. 이것은 그리 어렵지 않다. 오수네 포탈을 중심으로 했을 때 메인카메라의 상대 위치, 상대 회전이 어떤 값인지 구하고 짱구네 포탈을 중심으로 재배치해서 렌더링하면 된다. 이렇게 건너편 장면을 담기 위해 사용되는 카메라를 포탈카메라라고 하겠다.
상단에 있는 오수는 메인카메라, 하단에 있는 오수는 포탈카메라라고 생각하면 되겠다. 이제 포탈카메라를 통해 볼 때 출구포탈 영역에 투영되는 영역을 메인카메라의 입구포탈에 그려내면 된다.
어떤 장면을 그려야 할 지 알았으니 이제 벽에서 저 구멍 영역만큼을 오려내고 그려야 하는데 이건 어떻게 할 수 있을까? 구멍만큼 뚫린 벽 모델을 만들어서 그려야 할까? 웃기겠지만 나는 처음에 이 방법으로 구현하는 것에 대해 꽤 진지하게 고민했었다.
해답은 바로 ‘스텐실 검사’에 있다.
이 이미지들을 간단하게만 설명해 보겠다. 컬러 버퍼는 렌더링 파이프라인을 거치고 나서 결정된 픽셀의 최종 컬러 정보를 담는다. 만약 스텐실 검사가 없다면 첫 번째와 같이 화면에 그려지겠지만, 스텐실 버퍼의 값이 조건을 만족하는 경우에만 통과하도록 하는 스텐실 검사가 설정되어 있다면 세 번째와 같은 결과가 나올 것이다.
이 스텐실 검사를 적절히 활용하면 우리는 포탈 효과를 구현해 낼 수 있다.
포탈 렌더링
이제 본격적으로 포탈 렌더링 과정을 설명하겠다.
오렌지포탈 뒤에는 오렌지큐브를, 블루포탈 뒤에는 블루큐브를 배치해 두었다. 여기서 오렌지포탈은 입구포탈로, 블루포탈은 출구포탈로 이용하겠다.
입구포탈과 출구포탈
주관적으로 정리해 본 용어를 잠깐 설명하겠다. 이름 그대로 포탈이 입구로 취급되면 입구포탈, 출구로 취급되면 출구포탈이다. 이건 관점에 따라 다르게 정해지는 건데 각 포탈은 렌더링 과정에서 한 번은 입구포탈로, 또 한 번은 출구포탈로서 계산에 이용이 된다.
포탈 오브젝트 마스킹
첫 번째 목표는 포탈 오브젝트 마스킹을 통해 포탈이 현재 시야에서 보이는 범위가 어디인지 체크하는 것이다. 이건 다음과 같이 스텐실 검사를 설정하고 포탈 오브젝트를 렌더링하면 된다.
glStencilFunc(GL_ALWAYS, 1, 0xFF);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
스텐실 검사 함수에 관해서는 깊게 설명하지 않고 코드 작성 의도만 전하겠다.
“스텐실 버퍼의 값이 어떻든 스텐실 검사는 항상 통과하도록 하겠다. 그리고 스텐실 검사와 깊이 검사가 모두 성공한 경우에만 스텐실 버퍼의 값을 1로 지정하고, 그 외의 경우에는 스텐실 버퍼의 값을 변경하지 않겠다.”
이렇게 설정해 두고 입구포탈을 렌더링하면 시야에 포탈이 보이는 영역에 대해 스텐실 버퍼의 값이 1이 될 것이다. 이해를 돕기 위해 시각 자료를 준비했다.
스텐실 검사를 설정하고 검은색 프레임 안에 있는 오렌지포탈을 렌더링한다면 스텐실 버퍼가 어떻게 채워지는지 확인해 보자.
스텐실 버퍼의 값이 1인 영역들이 검은색으로 칠해졌다. 반면에 다른 영역들은 렌더링 할 때 별도로 스텐실 버퍼를 건드리지 않아서 값이 0이므로 칠해지지 않았다.
마스킹된 영역에 포탈 건너편 장면을 그려보자
이제 저 영역에 포탈 건너편 공간을 그릴 차례다.
glStencilFunc(GL_EQUAL, 1, 0xFF);
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
이 코드의 작성 의도는 다음과 같다.
“스텐실 버퍼의 값이 1인 경우에만 스텐실 검사가 통과하도록 하겠다. 그리고 스텐실 검사, 깊이 검사의 성공 여부와 관계없이 스텐실 버퍼의 값은 변경하지 않겠다.”
이렇게 설정해 두면 앞서 마스킹해 둔 영역만 스텐실 버퍼의 값이 1이므로 해당 부분만 렌더링된다. 아까 입구포탈을 중심으로 한 카메라의 상대위치, 상대회전을 구하고 출구포탈을 중심으로 해 재배치되는 것을 포탈카메라라고 했다.
포탈카메라에서 스텐실 버퍼 값이 1인 영역만 렌더링한다면 출구포탈 건너 화면이 보이게 될 것이다. 바로 아래 그림과 같이 말이다.
이제 이것을 기존 장면과 합치면 포탈 효과가 완성된다.
입구포탈을 통해 출구포탈 건너에 있는 블루큐브를 볼 수 있다.
마무리하며
이 프로젝트를 진행하면서 포탈의 시각적 효과 구현만 해도 고려해야 하는 요소가 상당히 많다는 것을 느꼈다. 이 글에서는 기초적인 부분만 다뤘지만 포탈을 더욱 현실적으로 구현하려면 (현실에는 포탈이 없지만) 더 많은 보완이 필요하다. 포탈카메라와 출구포탈 사이의 장애물 문제, 포탈 내부에서의 바닥면 z-fighting, 중첩 포탈 렌더링 등의 해결 과제가 남아있지만, 충분히 고민을 해본다면 해결이 가능할 것이라 생각한다. 그리고 유튜브에도 포탈을 어떻게 구현하는지 친절하게 설명해 주는 좋은 영상이 많으니 참고하면 좋을 것이다.
아래는 여러 개선을 통해 만들어낸 결과물이다. 포탈을 이리저리 움직여보면 재미있는 시각적 효과가 나타나는 것도 확인할 수 있다.