1) 3차원 감각
우리는 3차원 공간에 사는데도 불구하고 우리가 가진 감각 기관들 중에 독립적으로 3차원을 인지할 수 있는 기관은 없다. 시각은 시야각 이내에 들어오는 빛들이 망막이라는 2차원의 면에 맺혀서 생기는 감각이고, 청각은 양쪽 귀의 고막이 느끼는 음압으로부터 생기는 1차원의 정보에 지나지 않는다(청각은 소리의 세기 뿐만 아니라 음정도 있으니 2차원이라고 주장할 수도 있겠다.) 2차원의 시각 이미지나 두 개의 1차원 청각 신호로부터 한 개 혹은 두 개의 차원을 추가해 3차원의 공간을 유추하는 것은 우리의 오성과 인지능력이 열심히 일한 덕분이다. 이렇게 3차원보다 낮은 차원의 정보로부터 나머지 차원을 추가하여 공간을 인식하는 것은 상상력의 힘이다. 문득 예전에 봤던 어느 모의고사 지구과학영역 문제가 떠오른다. 4시 30분이라는 0차원의 정보 하나로부터 '달의 위치'라는 3차원 정보를 인지하기 위해서는 지구를 공전하는 달의 궤적에 대한 3차원어치 만큼의 상상력이 필요하다.
실전에서 이런 문제를 접하면 정신이 멍해진다.
다른 상상을 하나 더 해볼까, 눈을 감고 주변을 인식하는데 오로지 청각만에 의존한다고 상상해보자. 그리고 멀리서부터 자동차가 다가오는 소리가 들린다고 상상해보자. 자동차 소리는 현재 왼쪽 귀에서 좀더 크게 들리고 있다. 그런데 시간이 흐를수록 점점 더 커지더니 이내 오른쪽 귀에서 더 크게 들리더니 이내 점점 작아져간다. 이를 통해 자동차가 나에게 가까이 왔다가 점점 멀어져 갔다는 것을 인지하면서 이 물체가 그린 3차원 공간 속의 궤적을 유추해볼 수 있다. 이번에도 소리 크기의 변화와 시간의 변화라는 2차원의 정보로부터 1차원어치의 상상력을 더해 3차원을 인지한 것이다.
시각 역시 마찬가지다. 지금 내가 글을 쓰고 있는 카페의 창밖 풍경 역시 2차원의 정보이다. 창 밖 인도로 사람들이 지나 다니고 그 뒤의 도로로 차들이 지나다니고 도로 너머에 맞은편 건물들이 보인다. 이것이 2차원의 이미지임에도 불구하고 내가 창 밖의 공간을 3차원으로 인식할 수 있는 이유는 건물은 자동차보다 크고, 자동차는 사람보다 크다는 사실을 알고 있기 때문이다. 이로부터 내가 보고 있는 이미지는 사람이 자동차보다도, 건물보다도 더 크게 보이기 때문에 사람이 나보다 가까이, 자동차가 그보다는 좀 더 멀리, 건물은 그보다 더 멀리 있을 것이라는 거리감각을 인식하여 2차원 이미지로부터 3차원을 유추해낸 것이다.
하지만 만약 창문이 실제 유리가 아니라 아주 해상도가 높은 스크린이고, 미리 촬영 해둔 바깥의 모습을 재생하고 있다고 하더라도 [특정 조건]만 맞으면 나는 마치 창밖의 풍경을 바라보는 것과 같은 착각을 할 수 있다. 3D 그래픽을 구현한다는 것도 바로 이 지점에서 시작한다. 우리의 뇌가 그것을 3차원 공간의 물체라고 인지할 수 있도록 그러한 [특정 조건]에 부합하는 사실적인 힌트를 주는 것이다. 그 방법에 대해 앞으로 하나씩 알아보는 시간을 가져보자.
2) 원근법의 발명
사실 이러한 '힌트를 주는 방법'은 3D그래픽스라는 학문이 정립되기 훨씬 전부터 미술계가 오랫동안 고민해온 문제이다. 게다가 이미 꽤나 우아한 해법을 발명하였는데 그것이 바로 원근법이다.
인류의 미술사에서 원근법이 차지하는 중요도는 어마어마하다. 원근법이 발명된 이후로 작품안의 세계를 '나'라는 주체적 시선으로 바라보는 것을 묘사하기 시작하면서, 사람들이 세상을 인식하는 패러다임 자체가 신을 기준으로 하던 것에서 자기자신을 기준으로 하도록 변했기 때문이다. 이러한 발상의 전환이 자아 확립의 기초가 되어 근대 시민의식이 등장하게 되었다고 하니, 사상이라는 것이 새삼 얼마나 대단한 것인지 깨닫게 된다. 원근법이 발명되기 이전의 그림들의 공통점을 살펴보자. 작품 속의 피사체들이 모두 감상자로부터의 거리에 대한 개념이 전혀 담겨있지 않은 채 묘사되어 있는 것을 쉽게 확인할 수 있다.
가까운 것을 먼 것보다 앞에 배치함으로써 공간을 묘사하였다. 하지만 이 그림에서는 어떤 시점도 보이지 않는다.
멀리 있는 것은 다소 작게, 가까이에 있는 것은 보다 크게 그렸지만 거리감은 전혀 느껴지지 않는다.
창이 여러개 떠있는 바탕화면도 일종의 원근감 없는 공간을 보는 것과 같다.
대표적인 투시 원근법의 작품. 빨간색 선은 물체들의 거리감을 나타내기 위해 사용된 소실점을 표시해주고 있다.
가끔 현실 그래픽 엔진도 원근법 오류가 날 때가 있다.
3) ViewPort란?
ViewPort라는 단어를 사전에 검색을 해봐도 적절한 우리말은 찾을 수가 없다. 굳이 풀어서 설명하자면 '화면에 표시되는 영역'정도로 정의할 수 있겠다. 앞으로 구현해 나갈 3D공간은 이제부터 이렇게 생긴 Viewport라는 창문을 통해 보게 될 것이다. 이 Viewport에 어떻게 하면 적절하게 그림을 그릴 수 있는지에 대해 알아보기 전에, 이 Viewport라는 창문이 과연 어떤 속성을 지니는지 먼저 살펴보도록 하자.
ViewPort가 사용하는 좌표계는 왼쪽 위가 원점이고 X축은 오른쪽이 +, Y축은 아래쪽이 +방향이다. 우리가 수학시간에 자주 보던 좌표계와는 다소 차이가 있다. 왜 그런걸까?Viewport는 사용자와 바로 맞닿아 있는 부분이기 때문에 사람 중심으로 구성되는 것이 직관적이다. 무엇이 사람에게 직관적인 좌표계인지 궁금하다면, 우리가 책을 읽을 때 어디부터 읽는지를 떠올려보면 된다. 대부분의 문화권에서는 책을 읽을 때 가장 왼쪽 위에서 시작하여 오른쪽 아래 방향으로 글자를 읽어내려간다. 텍스트가 아닌 UI 컴포넌트들로 이루어진 화면이라 하더라도 사람들은 이미 익숙해져 있는 방향으로 시선이 움직인다. 따라서 Viewport의 좌표계는 왼쪽 위를 원점으로 지정하는 것이 합리적이라 할 수 있겠다. 하지만 우리가 바라보는 시선의 중심은 왼쪽 위가 아니라 화면의 정중앙을 바라보기 때문에 그림을 그릴 때 사용하는 좌표계는 ViewPort와는 달리 윈도우의 중심이 원점이어야 할 것이다. 원점이 왼쪽 위에 있는 좌표계는 앞으로 화면좌표계(Screen Coordinate System), 원점이 중심에 있는 좌표계는 정규좌표계(Normalised Device Coordinate)라고 하겠다.
검은건 SCS요, 하얀건 NDC이니라
윈도우 위의 임의의 점을 나타내는 좌표가 어느 좌표계에서 보느냐에 따라 달라진다는 것은 앞으로 익숙해져야 할 개념이다. 상태(위치)는 언제나 절대적이지만, 표현(좌표)은 언제나 상대적이다. 화면에 일단 뭐라도 그려보면서 익숙해져보는 시간을 갖자. 아래와 같이 사각형의 각 꼭짓점 정보를 담을 변수들을 클래스의 멤버로 선언해주자. X,Y 정보를 담는 구조체를 선언할 수도 있겠지만 그보다 편리한 JUCE에서 제공하는 Point라는 클래스가 있다. Generic 클래스기 때문에 선언할 때 Type을 지정해 줄 수 있고, 좌표에 관한 여러 사칙연산도 제공하기 때문에 2차원 벡터를 담기 적절하다. 여기서의 좌표는 정규좌표계를 따른다.
Point<float> leftTop = {0.5, 0.5}; Point<float> rightTop = {-0.5, 0.5}; Point<float> leftBot = {0.5, -0.5}; Point<float> rightBot = {-0.5, -0.5};
그 다음엔 이 사각형을 화면에 그려주는 코드를 추가하자. 이전 편에서 확인 했듯이 화면에 그림을 그리는 코드는 모두 paint에서 처리한다. 사각형은 각 꼭짓점 네 개를 연달아 잇도록 선을 그어주면 만들어질 것이다. 이것을 아래와 같이 코드로 표현할 수 있다.
g.drawLine({leftTop, rightTop}); g.drawLine({rightBot, rightTop}); g.drawLine({leftBot, rightBot}); g.drawLine({leftBot, leftTop});
우리가 추가한 꼭짓점들의 위치를 정규좌표계 위에 표시하자면 이러한 모습일 것이다. 하지만 이 코드를 실행해보면 화면에는 아무것도 그려지지 않는다. 왜냐하면 아직 사각형 꼭짓점들은 정규좌표계 값인데 Graphics 클래스의 draw함수들이 받는 인자들은 모두 화면좌표계의 좌표로 인식하기 때문이다. 따라서 정규좌표계의 좌표를 화면좌표계의 좌표로 변환을 해야 한다. 이것을 수식으로 나타내면 다음과 같다.
우선은 center라는 Point타입 변수를 하나 더 선언해주자. center는 정규좌표계의 원점좌표를 화면좌표계로 나타낸 점이다. 즉 화면의 너비와 높이의 절반이 각각의 X, Y값이 된다. 그 다음엔 변환을 위해 곱셈과 덧셈연산을 해야 하는데 Point 클래스에 구현되어 있는 operator*와 operator+를 보면 마침 우리가 사용하기 딱 적절하게 잘 구현되어 있다. 이를 이용해 정규좌표계를 화면좌표계로 변환할 수 있다.
/** Multiplies two points together */ template <typename OtherType> JUCE_CONSTEXPR Point operator* (Point<OtherType> other) const noexcept { return Point ((ValueType) (x * other.x), (ValueType) (y * other.y)); }
/** Adds two points together */ JUCE_CONSTEXPR Point operator+ (Point other) const noexcept { return Point (x + other.x, y + other.y); }
Component의 너비와 높이는 getWidth()와 getHeight()를 각각 이용해 가져올 수 있다. 현재는 생성자에서 초기화해준대로 600,400인데, 이것이 화면좌표계의 영역이다. NewComponent의 생성자에서 다음과 같이 꼭짓점의 정보를 새롭게 업데이트 해주자.
center = {getWidth()*0.5f, getHeight()*0.5f}; leftTop = center * leftTop + center; leftBot = center * leftBot + center; rightTop = center * rightTop + center; rightBot = center * rightBot + center;
담는 그릇이 비뚤어져 있으면 내용물도 비뚤어진다는 교훈을 얻었다.
실행시켜 봤더니 분명 정사각형을 그렸는데 어째서인지 직사각형이 나왔다. 왜 그런걸까? 정규좌표계는 원점에서부터 상하좌우의 길이가 모두 1인 추상적인 좌표계지만 화면좌표계는 실제로 화면에 표시되는 단위를 가진 좌표계이다. 좌표들이 정규좌표계로 표현되어있는 상태일 때는 실제 화면에 대한 정보를 전혀 모르는 상태이다. 화면좌표계로 변환하는 수식을 다시 한번 보자. 정규좌표계는 화면좌표계로 변환하는 시점에서야 비로소 ViewPort의 너비와 높이정보를 알 수 있기 때문에 정규좌표계에서는 정사각형일지라도 화면좌표계로 변환됐을 때도 정사각형일 것이라는 보장은 할 수가 없다. 이러한 속성은 간혹 유용하게 사용되기도 하지만 때로는 골칫거리가 되기도 한다. 화면좌표계로 변환하는 과정에서 이를 수습하는 꼼수가 있긴 하지만 우선은 편의를 위해 화면좌표계도 아래와 같이 정사각형 형태로 변경을 해주자. 생성자에서 setSize 메서드를 호출할 때 너비와 높이를 같은 값으로 주면 된다.
예쁜 정사각형을 성공적으로 그렸다.
4) 원근 투사
ViewPort는 2차원의 개념이기 때문에 그 위에 2차원의 그림을 그리는 것은 쉽게 상상할 수 있다. 하지만 평면에 3차원의 그림을 그리는 것은 약간 이야기가 다르다. 이번에는 정육면체를 화면에 그려보겠다. 정육면체는 3차원의 물체이므로 2차원 벡터를 나타내는 Point클래스로는 표현할 수 없다. 다행히 Juce에서는 3차원의 점을 표시할 수 있는 클래스도 제공한다. 바로 Vector3D 클래스이다. 다음과 같이 정육면체의 꼭짓점 8개를 정의하자.
Vector3D<float> leftTopFront = {0.5, 0.5, 0.5}; Vector3D<float> rightTopFront = {-0.5, 0.5, 0.5}; Vector3D<float> leftBotFront = {0.5, -0.5, 0.5}; Vector3D<float> rightBotFront = {-0.5, -0.5, 0.5}; Vector3D<float> leftTopBack = {0.5, 0.5, -0.5}; Vector3D<float> rightTopBack = {-0.5, 0.5, -0.5}; Vector3D<float> leftBotBack = {0.5, -0.5, -0.5}; Vector3D<float> rightBotBack = {-0.5, -0.5, -0.5};
이 좌표값에서 X, Y값만 뽑아 화면좌표계로 변환한다면 위에서 그렸던 그림의 결과와 똑같이 나올 수밖에 없을 것이다. Z좌표만 다르고 X, Y는 같은 점이 총 네 쌍이 있는데 Z값이 좌표 변환에서 어떤 영향도 주지 않기 때문에 당연한 현상이다. 이것을 3차원 공간의 관점에서 본다고 가정하자. 즉 아래의 그림과 같다.
정육면체 꼭짓점의 좌표의 z값에는 상관 없이 ViewPort에 그대로 투사된다. 꼭짓점이 ViewPort로부터 1만큼 떨어져있든 100만큼 떨어져있든 같은 위치에 투사되기 때문에 거리감을 느낄 수 없을 것이다. 하지만 우리의 눈은 세상을 이런식으로 인식하지 않는다. 이번에는 가상의 눈동자를 ViewPort 너머에 배치하는 것을 상상해보자. 눈으로 세상을 본다는 것은 시야 안에 들어오는 물체들로부터 사방으로 발사된 빛들 중에 바로 들어오든 어딘가 몇 번 부딪혀 반사되어 들어오든 우리 눈동자를 향해 직선으로 들어오는 빛만 인식할 수 있다는 뜻이다. 즉 정육면체 꼭짓점이 각각 빛을 쏜다고 가정하면 우리 눈을 향해 직선으로 들어오는 빛들은 아래의 그림과 같이 생겼을 것이다.
위에서 말했던 내용을 다시 떠올려보자. ViewPort는 일종의 창문이고 3D 그래픽을 구현한다는 것은 이 창문 밖을 바라보는 것처럼 그럴싸하게 보이도록 그림을 그리는 것이라고 했다. 즉 관찰자가 창문(ViewPort)밖에 있는 어떤 가상의 정육면체를 바라보고 있다고 믿도록 그럴싸하게 보여주고 싶다면 꼭짓점에서 쏘아진 광선이 창문의 어느 곳을 통과하여 우리 눈으로 들어오는 지를 계산하여 ViewPort의 그 위치에 각각의 빛깔을 찍어주면 된다는 뜻이다. 다만 이번에는 좌표계가 조금 다르다. 위에서는 직육면체의 빛이 한점으로 모이는 지점이 원점이다. 그리고 물체는 이 원점으로부터 얼마정도 떨어져 있는 것으로 표시된다. 물체의 원점(Model Origin)과 카메라의 원점(Camera Origin)과의 관계가 ViewPort에 보여지는 모습을 결정한다는 뜻이다. 3차원 공간의 투영의 가장 기본적인 원리기 때문에 이렇게 카메라의 원점을 기준으로 생각하는 것에 익숙해져야 한다.
ViewPort에 투영된 점의 좌표는 삼각형의 닮음을 이용하면 간단하게 계산할 수 있다. 삼각형의 닮음 정도야 다들 유치원 때 배우고 왔을테지만 그래도 짚고 넘어가자. 점 F가 실제 y좌표라면 ViewPort에 투사된 지점의 y좌표는 C가 될 것이다. 그리고 이 좌표를 포함하는 삼각형 DEF와 ABC는 닮음 관계에 있기 때문에 수식 (3)과 같은 관계가 성립한다. 이것을 다시 정리하면 ViewPort위의 y좌표를 수식 (4)로 정리할 수 있다. 이것을 x에 대해서도 똑같이 수행하면 ViewPort에 투영된 점의 좌표를 얻을 수 있는 것이다. 그렇다면, x좌표와 y좌표는 ViewPort위에 투영된 좌표로 새롭게 바꿨는데 z좌표는 어떻게 변할까 라는 질문을 던질 수 있을 것이다. 어차피 화면은 2차원인데 과연 변하는 것이 맞을까? 이것에 대해서는 나중에 화면의 Depth에 대해 다루면서 다시 알아보도록 할 것이고 지금은 무시하기로 한다.
위에서 알아본 공식을 통해 변환하는 공식을 코드로 구현해보자. 공식을 적용해야할 점은 8개이고, NDC좌표를 SCS좌표로 바꾸는 연산과 Viewport Projection하는 연산을 또 각각 수행해야 하기 때문에 코드 가독성을 위해 이것들을 함수로 먼저 구현해보도록 하겠다. NewComponent.h의 UserMethods 블럭 안에 다음과 같이 구현해주자.
//[UserMethods] -- You can add your own custom methods in this section. Point<float> getProjectionPoint(Vector3D<float> point) { float zp = 0.5f; point -= camera; return {zp * point.x/point.z, zp * point.y/point.}; } Point<float> toScreenPoint(Point<float> point) { return center * point + center; } //[/UserMethods]
다시 한 번 짚고 가자면, 3)의 zp는 카메라 원점으로부터 Viewport 평면의 거리를 나타낸다. 이 zp값은 카메라 원점에서 가장 가까운 물체의 z좌표보다 작아야 한다. 위에서 설명했듯이 Viewport를 바깥의 물체가 비춰진 창문이라고 가정하면 물체는 반드시 창문 밖에 있어야 하기 때문이다. (물론 창문보다 가까이 있는 물체는 투영하지 않는다는 가정이다.) 이제 Vector3D<float> 타입의 꼭짓점을 getProjectionPoint와 toScreenPoint 순서대로 거치면 우리가 원하는 3차원 좌표가 Viewport위에 투영된 2차원 좌표를 얻을 수 있다. 이를 통해 정육면체를 아래와 같이 그려보자.
void NewComponent::paint (Graphics& g) { //[UserPrePaint] Add your own custom painting code here.. //[/UserPrePaint] g.fillAll (Colour (0xff323e44)); //[UserPaint] Add your own custom painting code here.. g.setColour(Colours::white); g.drawLine({toScreenPoint(getProjectionPoint(leftTopFront)), toScreenPoint(getProjectionPoint(rightTopFront))}); g.drawLine({toScreenPoint(getProjectionPoint(leftTopFront)), toScreenPoint(getProjectionPoint(leftTopBack))}); g.drawLine({toScreenPoint(getProjectionPoint(leftTopFront)), toScreenPoint(getProjectionPoint(leftBotFront))}); g.drawLine({toScreenPoint(getProjectionPoint(rightTopBack)), toScreenPoint(getProjectionPoint(leftTopBack))}); g.drawLine({toScreenPoint(getProjectionPoint(rightTopBack)), toScreenPoint(getProjectionPoint(rightBotBack))}); g.drawLine({toScreenPoint(getProjectionPoint(rightTopBack)), toScreenPoint(getProjectionPoint(rightTopFront))}); g.drawLine({toScreenPoint(getProjectionPoint(rightBotFront)), toScreenPoint(getProjectionPoint(rightBotBack))}); g.drawLine({toScreenPoint(getProjectionPoint(rightBotFront)), toScreenPoint(getProjectionPoint(leftBotFront))}); g.drawLine({toScreenPoint(getProjectionPoint(rightBotFront)), toScreenPoint(getProjectionPoint(rightTopFront))}); g.drawLine({toScreenPoint(getProjectionPoint(leftBotBack)), toScreenPoint(getProjectionPoint(leftTopBack))}); g.drawLine({toScreenPoint(getProjectionPoint(leftBotBack)), toScreenPoint(getProjectionPoint(rightBotBack))}); g.drawLine({toScreenPoint(getProjectionPoint(leftBotBack)), toScreenPoint(getProjectionPoint(leftBotFront))}); //[/UserPaint] }
자세히 보아야 정육면체다. 너도 그렇다.
정육면체만 그려보고 완성했다고 하기엔 싱거우니 다른 입체도형을 그려보자. 삼각함수를 잘 조합하면 구를 쉽게 표현할 수 있다. 아래의 코드를 실행해보자.
void NewComponent::paint (Graphics& g) { //[UserPrePaint] Add your own custom painting code here.. //[/UserPrePaint] g.fillAll (Colour (0xff323e44)); //[UserPaint] Add your own custom painting code here.. g.setColour(Colours::white); float diff = M_PI/15; for(float azi = 0 ; azi<2 * M_PI ; azi += diff) { for(float elv = -M_PI_2 ; elv<M_PI_2 ; elv += diff) { g.drawLine({ toScreenPoint(getProjectionPoint({std::cosf(azi) * std::cosf(elv), std::sinf(elv), std::sinf(azi)*std::cosf(elv)})), toScreenPoint(getProjectionPoint({std::cosf(azi+diff) * std::cosf(elv), std::sinf(elv), std::sinf(azi+diff)*std::cosf(elv)})) }); g.drawLine({ toScreenPoint(getProjectionPoint({std::cosf(azi) * std::cosf(elv), std::sinf(elv), std::sinf(azi)*std::cosf(elv)})), toScreenPoint(getProjectionPoint({std::cosf(azi) * std::cosf(elv+diff), std::sinf(elv+diff), std::sinf(azi)*std::cosf(elv+diff)})) }); } } //[/UserPaint] }
확실히 입체감이 느껴진다.
'개발 > JUCE Framework' 카테고리의 다른 글
[3D 그래픽 엔진을 만들어보자] - 3. JUCE GUI 프로그래밍 (0) | 2018.07.01 |
---|---|
[3D 그래픽 엔진을 만들어보자] - 2. JUCE Framework의 기본적인 구조 (0) | 2018.06.28 |
[3D 그래픽 엔진을 만들어보자] - 1. Juce Framework 튜토리얼 (0) | 2018.06.28 |
[3D 그래픽 엔진을 만들어보자] - 0. 들어가며 (2) | 2018.06.28 |