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로 간단하게 윈도우를 생성하고 이를 구현하는 내부 코드들을 간단히 살펴보았다. 이번 챕터에서는 JUCE에서 기본적으로 제공하는 GUI Component들에 대해 살펴보고 이것들의 내부 코드 또한 하나씩 살펴보는 시간을 갖고자 한다. 

GUI Component란 Button, Slider, ComboBox, TableView, TreeView등의 Graphic User Interface 구성요소를 통틀어 말하는데 GUI 프로그래밍을 지원하는 Framework라면 기본적으로 이 Component를 구현해놓고 있다. JUCE Framework 역시 굉장히 다양하고 훌륭한 GUI Component들을 제공하는데 이것들의 코드를 살펴보고 어떻게 동작하는지 구조를 간략히 파악해보면서 JUCE Framework 이해에 한걸음 더 다가가 보고자 한다.


1) Projucer를 이용한 GUI Component 배치

첫번째 챕터를 통해 Projucer를 이용한 IDE Project 파일을 만드는 것을 실습해보았는데, 사실 Projucer에는 이것 말고도 훌륭한 기능이 한 가지 더있다! 바로 WYSIWYG 스타일로 즉, 코드를 치지 않고도 Drag&Drop만으로도 GUI Component를 배치할 수 있다는 것이다. 원하는 Component를 선택해서 원하는 위치에 배치하면 Projucer가 알아서 그에 해당하는 C++코드를 생성해준다. 설명이 길면 재미없으니깐 얼른 만지작거리러 가보자.




File explorer 탭을 열고 오른쪽 버튼을 클릭하면 다음과 같은 항목들이 등장한다. 우리는 여기서 두번째 블록 맨 아래의 [Add New GUI Component...] 라는 항목을 선택할 것이다. 그러면 새로운 소스파일을 저장할 경로를 선택하는 창이 뜰 것이다. Source 폴더의 아래로 들어가 원하는 이름을 붙여주자. 나는 NewComponent라는 이름을 붙이기로 했다.

그러면 자동으로 우리가 설정한 이름을 가진 헤더파일과 CPP파일이 각각 생성된 것이 보인다. 재밌는 점은 CPP파일을 선택하면 코드를 보여주지 않고 이런 탭메뉴를 보여준다는 것이다. 첫번째 탭은 일단 건너 뛰고 빨리 두번째 SubComponent Tab을 열어보자 빨리빨리.


여기서 오른쪽 마우스를 클릭



모눈종이같은 공간을 오른쪽 클릭하면 우리가 여기에 추가할 수 있는 GUI Component의 종류들이 좕 뜬다. 가장 위에 있는 New Text Button을 클릭해서 새로운 Text Button Component를 만들어보자. 그러면 우리의 첫 GUI Component가 드디어 세상의 빛을 보게 될 것이다. 이렇게 만들어진 button에 대한 속성은 오른쪽에 모두 나타나있다. member name은 코드 상에서 이 버튼에 접근할 수 있는 이름이고 name은 이 button의 속성으로 저장되어 Runtime에서 접근할 수 있는 정보이다. virtual class는 이 Component의 선언 타입인데, 따로 값을 입력하지 않으면 기본값으로 TextButton 이라는 타입으로 선언이 된다. 이 아래로는 모두 이 버튼의 외관을 결정하는 속성들이니 하나씩 건드려보며 어떻게 변하는지 직접 느껴보면 좋다.


지금까지의 상태를 간단히 짚고 넘어가자면, 우리는 NewComponent라는 객체를 새롭게 만들었고 그 안에 TextButton 객체를 추가했다. NewComponent와 TextButton는 서로가 부모 자식 관계에 놓인 객체인 것이다. 그렇다면 저 오른쪽 속성창에 있는 Generate ButtonListener라는 체크박스는 무엇을 뜻할까? 이 TextButton이 눌렸을 때 발생하는 Event를 부모객체인 NewComponent가 수신하도록 한다는 뜻이다. 그림으로 표현하자면 이러하다.

0xFF00CA80은 아무렇게나 적어본 NewComponent의 메모리 주소이다.


Button은 NewComponent의 멤버 중 하나인데, 이때 Button은 buttonListeners라는 List에 자신의 부모 즉, NewComponent의 포인터를 담고 있다. Button이 사용자로부터 mouseDown Event를 받으면 buttonListeners의 모든 멤버가 buttonClicked 메서드를 호출하도록 되어있다. 그리고 이 과정은 Button에서 mouseDown이 발생하는 순간 Synchronous하게 이뤄진다. 이 동작에 대해서는 아래에서 코드를 보며 다시 한 번 살펴보겠다. 일단은 NewComponent에 대해 좀 더 자세한 설명을 위해 급하게 건너뛰었던 첫번째 탭으로 돌아가보겠다.



첫번째 탭인 'Class'에서는 이 Component의 전반적인 속성을 설정할 수 있다. Class name은 이 Component의 실제 이름을 가리키며, 코드상에서 접근할 수 있는 이름이다. Template file에는 이러한 설정 등을 통해 자동으로 생성되는 코드의 Template을 설정한 file 경로를 입력할 수 있는데 기본적으로도 이미 Template이 잘 갖춰져 있기 때문에 필수적이지는 않다. Component name은 이 Component가 내부적으로 갖게되는 이름인데 Attribute로 지니고 있기 때문에 Runtime에서도 접근할 수 있는 정보이다.


그 다음에 있는 Parent classes라는 이름을 주목해보자. 단수가 아닌 복수로 쓰인 이유는 바로 NewComponent가 이 항목에 입력되는 Class들을 모두 상속받게 된다는 뜻이다. 지금은 'Component'라는 클래스만을 상속받도록 되어있지만 콤마(,)를 이용해 더 추가할 수도 있다. 이것을 이해했다면 아래의 두 항목도 용도가 쉽게 유추된다. Constructor params는 이 Component Class의 생성자가 받을 인자들에 대한 정보이고, Member initialisers는 생성자 호출시 초기화 해줄 멤버를 입력하는 곳이다.


그리고 아래의 나머지는 이 Component의 높이와 너비를 입력하고 크기를 고정시킬건지 유동적으로 변화시킬 것인지를 설정하는 곳이다.


오른쪽에 있는 리스트들 또한 매우 흥미를 당긴다. 이름을 통해 유추해보자면, 이 Component에 어떤 이벤트가 발생했을 때의 처리여부와 그 때 실행할 콜백함수에 대한 정보가 담겨있다. 마우스가 눌렸을 때, 다른 Component들과의 토폴로지가 변경됐을 때, 자식 Component에 변화가 발생했을 때 등 엄청나게 많은 Event들을 등록할 수 있게 되어있다. 이 NewComponent 객체가 이전 탭에서 추가했던 ButtonListener이외에도 수많은 Event들을 수신할 수 있다는 뜻이다. 이것들만 잘 이용해도 상당히 동적인 반응형 어플리케이션을 구현할 수 있다. 이 Event를 처리하는 것에 대해서도 역시 이후에 좀 더 자세히 다뤄보겠다.


일단은 이 버튼이 추가된 창을 빨리 실행해보고 싶을 뿐이다. 상단의 Xcode(혹은 Visual Studio 등)의 아이콘을 눌러 IDE로 넘어간 뒤 빌드하고 실행을 해보자.



버튼을 추가했지만 어째선지 윈도우에는 변함없이 인사만 건네고 있다. 우리가 만들었던 버튼은 어디로 가있는 걸까? 결론부터 말하자면 버튼이 코드 상으로는 존재하지만 실제 윈도우 위에 로드를 하지 않았기 때문에 보이지 않는 것이다. 이제 우리가 지난 챕터에서 모른척 하고 어물쩡 넘어가버렸던 Main.cpp 파일을 열어볼 차례이다.


/*
  ==============================================================================

    This file was auto-generated!

    It contains the basic startup code for a JUCE application.

  ==============================================================================
*/

#include "../JuceLibraryCode/JuceHeader.h"
#include "MainComponent.h" // -> "NewComponent.h"

//==============================================================================
class JUCEPractice1Application  : public JUCEApplication
{
public:
    //==============================================================================
    JUCEPractice1Application() {}

    const String getApplicationName() override       { return ProjectInfo::projectName; }
    const String getApplicationVersion() override    { return ProjectInfo::versionString; }
    bool moreThanOneInstanceAllowed() override       { return true; }

    //==============================================================================
    void initialise (const String& commandLine) override
    {
        // This method is where you should put your application's initialisation code..

        mainWindow = new MainWindow (getApplicationName());
    }

    void shutdown() override
    {
        // Add your application's shutdown code here..

        mainWindow = nullptr; // (deletes our window)
    }

    //==============================================================================
    void systemRequestedQuit() override
    {
        // This is called when the app is being asked to quit: you can ignore this
        // request and let the app carry on running, or call quit() to allow the app to close.
        quit();
    }

    void anotherInstanceStarted (const String& commandLine) override
    {
        // When another instance of the app is launched while this one is running,
        // this method is invoked, and the commandLine parameter tells you what
        // the other instance's command-line arguments were.
    }

    //==============================================================================
    /*
        This class implements the desktop window that contains an instance of
        our MainComponent class.
    */
    class MainWindow    : public DocumentWindow
    {
    public:
        MainWindow (String name)  : DocumentWindow (name,
                                                    Desktop::getInstance().getDefaultLookAndFeel()
                                                                          .findColour (ResizableWindow::backgroundColourId),
                                                    DocumentWindow::allButtons)
        {
            setUsingNativeTitleBar (true);
            setContentOwned (new MainComponent(), true);// -> setContentOwned (new NewComponent(), true);

            centreWithSize (getWidth(), getHeight());
            setVisible (true);
        }

        void closeButtonPressed() override
        {
            // This is called when the user tries to close this window. Here, we'll just
            // ask the app to quit when this happens, but you can change this to do
            // whatever you need.
            JUCEApplication::getInstance()->systemRequestedQuit();
        }

        /* Note: Be careful if you override any DocumentWindow methods - the base
           class uses a lot of them, so by overriding you might break its functionality.
           It's best to do all your work in your content component instead, but if
           you really have to override any DocumentWindow methods, make sure your
           subclass also calls the superclass's method.
        */

    private:
        JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainWindow)
    };

private:
    ScopedPointer<MainWindow> mainWindow;
};

//==============================================================================
// This macro generates the main() routine that launches the app.
START_JUCE_APPLICATION (JUCEPractice1Application)

이전까지 봤던 JUCE 코드들과는 다르게 뭔가 무시무시하다. 하지만 겁먹을 필요는 없다. 우리가 신경써야 할 라인은 12번째 줄과 69번째 줄 뿐이다. 12)에서는 앞서 만들어져 있던 MainComponent에 대한 정보가 담긴 헤더파일을 코드에 추가하고, 69)에서는 앞서 선언된 MainComponent를 생성하고 있다. 이 코드들만 각각 NewComponent로 교체해주면 된다. 이제 다시 빌드하고 실행을 해보자.



짜잔잔 버튼이 생겨났다. 클릭해도 아무 일은 일어나지 않지만 그래도 재밌고 신기하다. 이제 저 버튼이 생겨나기까지 코드에서는 무슨 일이 있었는지 알아보러 얼른 가보자.


2) NewComponent.h

/*
  ==============================================================================

  This is an automatically generated GUI class created by the Projucer!

  Be careful when adding custom code to these files, as only the code within
  the "//[xyz]" and "//[/xyz]" sections will be retained when the file is loaded
  and re-saved.

  Created with Projucer version: 5.3.1

  ------------------------------------------------------------------------------

  The Projucer is part of the JUCE library.
  Copyright (c) 2017 - ROLI Ltd.

  ==============================================================================
*/

#pragma once

//[Headers]     -- You can add your own extra header files here --
#include "../JuceLibraryCode/JuceHeader.h"
//[/Headers]



//==============================================================================
/**
                                                                    //[Comments]
    An auto-generated component, created by the Projucer.

    Describe your class and how it works here!
                                                                    //[/Comments]
*/
class NewComponent  : public Component,
                      public Button::Listener
{
public:
    //==============================================================================
    NewComponent ();
    ~NewComponent();

    //==============================================================================
    //[UserMethods]     -- You can add your own custom methods in this section.
    //[/UserMethods]

    void paint (Graphics& g) override;
    void resized() override;
    void buttonClicked (Button* buttonThatWasClicked) override;



private:
    //[UserVariables]   -- You can add your own custom variables in this section.
    //[/UserVariables]

    //==============================================================================
    ScopedPointer<TextButton> textButton; // std::unique_ptr<TextButton> textButton;


    //==============================================================================
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NewComponent)
};

//[EndFile] You can add extra defines here...
//[/EndFile]

NewComponent의 헤더파일을 열어보자. 우선은 Component 클래스만 상속 받다가 Button::Listener 클래스도 추가로 상속받는 것을 확인해볼 수 있다. 더불어 Button::Listener에 선언되어 있는 buttonClicked 메소드도 재정의하도록 추가됐다. 그리고 우리가 화면에 새롭게 배치했던 textButton 멤버도 private영역에 새롭게 선언되어 있다. 흥미로운 것은 TextButton이 ScopedPointer의 Generic Type으로 선언되어 있다는 점인데, ScopedPointer 클래스는 C++표준에서 제공하는 스마트포인터와 같은 역할을 한다. 그래서 그런지는 몰라도 가장 최신버전인 JUCE 5.3.2버전 부터 Deprecate되어 더 이상 사용되지 않는다. 하지만 이 튜토리얼은 5.3.1버전으로 작성되었기 때문에 일단은 저것이 스마트포인터와 똑같다는 것만 알고 넘어가면 되겠다.


스마트포인터로 버튼을 선언한 이유는 간단하다. NewComponent에 속해있는 객체이기 때문에 NewComponent가 소멸되면 TextButton객체 역시 더 이상 어디에도 필요가 없기 때문에 Composition 형태로 NewComponent에 종속시키려는 목적이다. 처음부터 포인터 타입이 아니라 일반 멤버 변수로 선언하면 ('TextButton textButton;' 이런식으로) 어차피 NewComponent가 소멸될 때 알아서 메모리가 해제되지 않겠냐는 질문을 던져볼 수도 있겠다. 그것에 대한 대답은 NewComponent.cpp에 담겨있다.


3) NewComponent.cpp

/*
  ==============================================================================

  This is an automatically generated GUI class created by the Projucer!

  Be careful when adding custom code to these files, as only the code within
  the "//[xyz]" and "//[/xyz]" sections will be retained when the file is loaded
  and re-saved.

  Created with Projucer version: 5.3.1

  ------------------------------------------------------------------------------

  The Projucer is part of the JUCE library.
  Copyright (c) 2017 - ROLI Ltd.

  ==============================================================================
*/

//[Headers] You can add your own extra header files here...
//[/Headers]

#include "NewComponent.h"


//[MiscUserDefs] You can add your own user definitions and misc code here...
//[/MiscUserDefs]

//==============================================================================
NewComponent::NewComponent ()
{
    //[Constructor_pre] You can add your own custom stuff here..
    //[/Constructor_pre]

    addAndMakeVisible (textButton = new TextButton ("new button"));
    textButton->addListener (this);

    textButton->setBounds (225, 188, 150, 24);


    //[UserPreSize]
    //[/UserPreSize]

    setSize (600, 400);


    //[Constructor] You can add your own custom stuff here..
    //[/Constructor]
}

NewComponent::~NewComponent()
{
    //[Destructor_pre]. You can add your own custom destruction code here..
    //[/Destructor_pre]

    textButton = nullptr;


    //[Destructor]. You can add your own custom destruction code here..
    //[/Destructor]
}

//==============================================================================
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..
    //[/UserPaint]
}

void NewComponent::resized()
{
    //[UserPreResize] Add your own custom resize code here..
    //[/UserPreResize]

    //[UserResized] Add your own custom resize handling here..
    //[/UserResized]
}

void NewComponent::buttonClicked (Button* buttonThatWasClicked)
{
    //[UserbuttonClicked_Pre]
    //[/UserbuttonClicked_Pre]

    if (buttonThatWasClicked == textButton)
    {
        //[UserButtonCode_textButton] -- add your button handler code here..
        //[/UserButtonCode_textButton]
    }

    //[UserbuttonClicked_Post]
    //[/UserbuttonClicked_Post]
}



//[MiscUserCode] You can add your own definitions of your custom methods or any other code here...
//[/MiscUserCode]
#endif


//[EndFile] You can add extra defines here...
//[/EndFile]

생성자의 코드의 첫 줄을 살펴보면 addAndMakeVisible이라는 함수를 호출과 동시에 textButton에 메모리를 할당하는 것을 볼 수 있다. 그리고 추가적으로 textButton의 초기화를 위한 작업들이 아래에 이어 등장한다. 클래스 타입의 멤버를 포인터 변수로 선언했을 때의 장점은 초기화 시점을 개발자가 직접 정할 수 있다는 점이다. 지금은 NewComponent 안에 TextButton Component만 있지만 여러개의 하위 Component들이 선언되어 있고 이들이 서로 어떠한 종속관계를 맺어야 한다면 생성자에 모든 것을 맡기기 보다는 비교적 덜 안전하더라도 개발자가 직접 초기화 시점을 정할 수 있도록 스마트포인터 형태로 만들어 주는 것이다. 그래도 56)을 보면 코드의 명료함을 위해 소멸자에서 스마트포인터의 메모리를 수동으로 해제하고 있다.


35)에서는 textButton->addListener(this)라는 함수를 통해 textButton이 어떤 Event를 받았을 때, 그 Event를 받았다는 사실을 알려주도록 NewComponent를 textButton의 Listener에 등록하고 있다. 그렇다면 이것이 어떻게 버튼이 눌렸을 때 NewComponent의 buttonClicked가 호출되도록 동작하는 것일까? Button의 내부 코드를 한번 쫒아가 보자.


/* juce_Button.cpp */
void Button::mouseDown (const MouseEvent& e)
{
    updateState (true, true);

    if (isDown())
    {
        if (autoRepeatDelay >= 0)
            callbackHelper->startTimer (autoRepeatDelay);

        if (triggerOnMouseDown)
            internalClickCallback (e.mods); // <-- 여러 복잡한 과정을 지나 아무튼 이 함수가 호출된다.
    }
}

Button은 Component 클래스를 상속받아서 마우스가 눌릴 때 불리는 mouseDown Callback을 재정의 했다.

/* juce_Button.cpp */
void Button::internalClickCallback (const ModifierKeys& modifiers)
{
    if (clickTogglesState)
    {
        const bool shouldBeOn = (radioGroupId != 0 || ! lastToggleState);

        if (shouldBeOn != getToggleState())
        {
            setToggleState (shouldBeOn, sendNotification);
            return;
        }
    }

    sendClickMessage (modifiers); // <-- 그랬더니 또 이런저런 복잡한 과정을 지나 여차저차 이 함수가 호출된다.
}
/* juce_Button.cpp */
void Button::sendClickMessage (const ModifierKeys& modifiers)
{
    Component::BailOutChecker checker (this);

    if (commandManagerToUse != nullptr && commandID != 0)
    {
        ApplicationCommandTarget::InvocationInfo info (commandID);
        info.invocationMethod = ApplicationCommandTarget::InvocationInfo::fromButton;
        info.originatingComponent = this;

        commandManagerToUse->invoke (info, true);
    }

    clicked (modifiers);

    if (checker.shouldBailOut())
        return;

    buttonListeners.callChecked (checker, [this] (Listener& l) { l.buttonClicked (this); }); //<-- 이곳에서 buttonListener 안에 등록된 Listener들의 buttonClicked를 호출해준다.

    if (checker.shouldBailOut())
        return;

    if (onClick != nullptr)
        onClick();
}

buttonListeners는 addListener를 통해 인자로 넘어온 것들을 저장하는 List 타입의 멤버이다. sendClickMessage의 20)에서 buttonListeners 안의 멤버가 모두 buttonClicked를 호출하도록 하는 것을 확인할 수 있다. 이 때 Callback 인자로 자신을 넘겨주는 것을 확인할 수 있다. 그 이유는 아래에서 설명하도록 하겠다.



36)에서는 textButton이 newComponent에서 어느 곳에 위치하는지 왼쪽 위의 좌표(x,y)와 높이 너비(width, height)를 차례로 설정해준다. 이전 편에서 잠시 살펴봤던 resized를 떠올려보자. 

virtual void Component::resized ( ) 

A component can implement this method to do things such as laying out its child components when its width or height changes. The method is called synchronously as a result of the setBounds or setSize methods, so repeatedly changing a components size will repeatedly call its resized method (unlike things like repainting, where multiple calls to repaint are coalesced together). If the component is a top-level window on the desktop, its size could also be changed by operating-system factors beyond the application's control.

이 Callback은 setBounds나 setSize가 호출되었을 때 syncronous하게 실행된다고 하였다. 36)이 실행되었을 때 역시 자동으로 textButton의 resize가 이어서 실행될 것임을 예상할 수 있다.


4) Event 등록

버튼도 만들어 봤으니 이제는 버튼이 눌렸을 때(Event) 실제로 어떤 일이 일어나도록(Callback) 해보겠다. Button이 눌린 횟수를 기록해서 화면에 표시해주는 기능을 만들어볼 것이다. 우선은 다시 Projucer로 돌아가보자.

이번에는 Text Button이 아닌 Label을 새로 추가해보겠다. stateLabel이라는 멤버 이름을 정해주고 기본 값으로 'Nothing Happend'라는 String을 띄워주도록 설정한다. 이것이 코드로는 다음과 같이 반영되어 있을 것이다. Label Component가 추가되면서 어떤 코드가 새로 추가됐는지 유심히 살펴보길 바란다.


/* NewComponent.h */ class NewComponent : public Component, public Button::Listener { public: //============================================================================== NewComponent (); ~NewComponent(); //============================================================================== //[UserMethods] -- You can add your own custom methods in this section. //[/UserMethods] void paint (Graphics& g) override; void resized() override; void buttonClicked (Button* buttonThatWasClicked) override; private: //[UserVariables] -- You can add your own custom variables in this section.     int cnt; //[/UserVariables] //============================================================================== ScopedPointer<TextButton> textButton; ScopedPointer<Label> stateLabel; //============================================================================== JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NewComponent) };

/* NewComponent.cpp */
NewComponent::NewComponent ()
{
    //[Constructor_pre] You can add your own custom stuff here..
    //[/Constructor_pre]

    addAndMakeVisible (textButton = new TextButton ("new button"));
    textButton->addListener (this);

    textButton->setBounds (225, 188, 150, 24);

    addAndMakeVisible (stateLabel = new Label ("new label",
                                               TRANS("Nothing Happend")));
    stateLabel->setFont (Font (15.00f, Font::plain).withTypefaceStyle ("Regular"));
    stateLabel->setJustificationType (Justification::centredLeft);
    stateLabel->setEditable (false, false, false);
    stateLabel->setColour (TextEditor::textColourId, Colours::black);
    stateLabel->setColour (TextEditor::backgroundColourId, Colour (0x00000000));

    stateLabel->setBounds (224, 152, 150, 24);


    //[UserPreSize]
    //[/UserPreSize]

    setSize (600, 400);


    //[Constructor] You can add your own custom stuff here..
    cnt = 0;
    //[/Constructor]
}

버튼이 눌린 횟수를 기록하기 위해 헤더파일과 소스파일의 22)와 30)에 integer 변수 cnt를 선언했다. 주의해야 할 점은 이런 template Block의 바깥에 코드를 작성하면 Projucer에서 코드를 자동으로 생성할 때 모두 지워진다는 것이다. 나도 처음에는 이걸 모르고 Projucer를 사용했다가 여러번 낭패를 봤었다. 친절하게도 Block이 굉장히 세분화돼서 만들어져 있기 때문에 규칙만 잘 지키면 얼마든지 자유롭게 코딩할 수 있다. 이제는 버튼의 Event가 발생했을 때 호출되는 Callback 함수 buttonClicked로 가보자.


void NewComponent::buttonClicked (Button* buttonThatWasClicked)
{
    //[UserbuttonClicked_Pre]
    //[/UserbuttonClicked_Pre]

    if (buttonThatWasClicked == textButton)
    {
        //[UserButtonCode_textButton] -- add your button handler code here..
        cnt++;
        stateLabel->setText("Clicked " + String(cnt) + " times");
        //[/UserButtonCode_textButton]
    }

    //[UserbuttonClicked_Post]
    //[/UserbuttonClicked_Post]
}

NewComponent는 textButton이외에도 다른 ButtonListener에 등록될 수 있다. 현재는 textButton 하나에만 등록되어 있지만 만약에 버튼이 여러개가 있고 그 버튼 모두의 Event에 Listener로 등록되더라도 모두 공통적으로 buttonClicked라는 이 Callback을 호출할 것이다. 그렇기 때문에 Callback 인자로 어느 버튼이 눌린 것인지 알려주고 있다.

이를 통해 NewComponent가 가진 Button 멤버들과 메모리 주소를 비교하여 어떤 버튼인지 식별한 뒤 각각에 해당하는 동작을 수행하는 것이다.  6)에서도 이와 같은 방법으로 textButton임을 식별하여 9)눌린 횟수를 증가시켜주고 10)stateLabel의 텍스트를 업데이트한다. 이제 준비는 다 되었다. 빌드를 한 뒤 실행해서 잘 동작하는 것을 확인해보자.


열심히 누른 만큼 카운터도 정직하게 올라간다. 세상 모든 일이 이렇게 정직하면 좋으련만.


지난 편에 이어서, 오늘은 JUCE Framework가 어떤 구조로 이루어져 있길래 이렇게 편리하게 C++ 크로스 플랫폼 개발이 가능한지 좀 더 자세히 살펴볼 것이다. 다행히 JUCE는 오픈소스기 때문에 모든 코드를 다 헤집어 살펴볼 수 있다. 전부 이해할 수 있는지는 이후의 문제지만... 우선은 이전 편에서 만들었던 프로젝트의 코드를 잠깐 짚어보고 가자.

1) JuceHeader.h

/*
   IMPORTANT! This file is auto-generated each time you save your project 
    - if you alter its contents, your changes may be overwritten!    
    This is the header file that your files should include in order to get all the
    JUCE library headers. You should avoid including the JUCE headers directly in
    your own source files, because that wouldn't pick up the correct configuration
    options for your app.

*/

#pragma once

#include "AppConfig.h"

#include <juce_audio_basics/juce_audio_basics.h>
#include <juce_audio_devices/juce_audio_devices.h>
#include <juce_audio_formats/juce_audio_formats.h>
#include <juce_audio_processors/juce_audio_processors.h>
#include <juce_core/juce_core.h>
#include <juce_cryptography/juce_cryptography.h>
#include <juce_data_structures/juce_data_structures.h>
#include <juce_events/juce_events.h>
#include <juce_graphics/juce_graphics.h>
#include <juce_gui_basics/juce_gui_basics.h>
#include <juce_gui_extra/juce_gui_extra.h>
#include <juce_opengl/juce_opengl.h>
#include <juce_video/juce_video.h>


#if ! DONT_SET_USING_JUCE_NAMESPACE
 // If your code uses a lot of JUCE classes, then this will obviously save you
 // a lot of typing, but can be disabled by setting DONT_SET_USING_JUCE_NAMESPACE.
 using namespace juce;
#endif

#if ! JUCE_DONT_DECLARE_PROJECTINFO
namespace ProjectInfo
{
    const char* const  projectName    = "JUCEPractice1";
    const char* const  versionString  = "1.0.0";
    const int          versionNumber  = 0x10000;
}
#endif

MainComponent.h 파일의 상단에는 JuceHeader.h라는 헤더파일이 덩그러니 하나 선언되어있다. 그 파일을 열어보면 JUCE Framework가 가진 모듈들을 대략 살펴볼 수 있다. 아무래도 Audio Programming에 특화된 Framework다 보니 audio에 관련된 모듈이 많이 있고, 더불어 GUI에 관련된 모듈도 상당히 많이 있다. 헤더의 상단에 주석으로 경고하고 있듯이 이 헤더파일은 자동으로 생성된다. 이를 통해 Projucer에서 module에 대한 설정을 바꿔주면 이 파일이 영향을 받는다는 사실을 유추해볼 수 있겠다.

Projucer에 들어가 이번에는 왼쪽 메뉴에서 Modules 항목을 눌러보자. 하위 리스트에 있는 항목들이 방금 전에 확인했었던  JuceHeader.h에 포함된 항목들과 일치한다. 그 외에 추가로 모듈을 더 추가하고 싶다면 '+'버튼을 눌러서 추가하면 된다. 재밌는 것은 우리가 직접 juce module을 만들 수도 있다는 점인데 그 내용에 대해서도 언젠가 다뤄볼 수 있을 것 같다.


2) MainComponent.h

/*
  ==============================================================================

    This file was auto-generated!

  ==============================================================================
*/

#pragma once

#include "../JuceLibraryCode/JuceHeader.h"

//==============================================================================
/*
    This component lives inside our window, and this is where you should put all
    your controls and content.
*/
class MainComponent   : public Component
{
public:
    //==============================================================================
    MainComponent();
    ~MainComponent();

    //==============================================================================
    void paint (Graphics&) override;
    void resized() override;

private:
    //==============================================================================
    // Your private member variables go here...


    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainComponent)
};

이번에는 MainComponent.h를 살펴보자. 기본적인 C++ Class 문법에 맞게 생성자와 소멸자가 선언되어 있고, paint와 resized라는 메서드가 선언되어있다. Component라는 클래스로부터 상속받은 것들이기 때문에 override 마킹이 되어있다. 매크로로도 무언가 선언되어 있는데, 이름으로 보아 복사방지와 메모리릭을 감지하기 위한 용도라는 것을 알 수 있다. 지금은 중요한 것이 아니니 넘어가도록 하겠다. 


Component 클래스는 JUCE Framework의 거의 모든 UI 관련 객체의 base가 되는 클래스이다. 실제 화면에 대한 표시 위치와 영역, 그려질 때의 생김새 그리고 다양한 사용자 이벤트 처리에 대해 선언되어 있다. JUCE는 가장 기본적인 UI Component인 Button부터 시작해서 Label, ComboBox, Slider, ListView등의 수많은 Basic UI Component들이 미리 구현되어 있는데 모두 이 Component 클래스를 상속받아 만들어진 것들이다. 이 멋진 녀석을 들여다보기 위해 Component 클래스가 선언된 파일을 직접 열어서 확인해 볼 수도 있겠지만, 더 좋은 방법은 문서를 확인하는 것이다. 운 좋게도, JUCE Framework는 코드의 퀄리티 만큼이나 Documentation의 정리도 매우 잘 되어 있다. 아래의 링크를 따라가 보자.


https://docs.juce.com/master/classComponent.html

Framework를 배우기 가장 좋은 방법은 문서를 읽는 것이듯, Framework를 사용자들에게 널리 알리기 가장 좋은 방법은 문서화를 잘 하는 것이다. 그런 의미에서 JUCE Framework는 한 번 빠지면 헤어나올 수 없을 정도로 문서화와 Tutorial이 잘 만들어져 있는 Framework이다. 한가할 때는 정말 JUCE Framework가 또 무슨 기능들을 갖고 있나 보고 싶어서 재미로 문서를 읽기도 한다.


Component 클래스가 가진 메서드와 멤버들을 모두 이 자리에서 소개할 수는 없으니, MainComponent가 상속한 두 개의 메서드; paint와 resized에 대해서만 살펴보겠다. JUCE의 문서에는 다음과 같이 소개되어 있다.

virtual void Component::paint ( Graphics & g ) 

The paint() method gets called when a region of a component needs redrawing, either because the component's repaint() method has been called, or because something has happened on the screen that means a section of a window needs to be redrawn.

Any child components will draw themselves over whatever this method draws. If you need to paint over the top of your child components, you can also implement the paintOverChildren() method to do this.

If you want to cause a component to redraw itself, this is done asynchronously - calling the repaint() method marks a region of the component as "dirty", and the paint() method will automatically be called sometime later, by the message thread, to paint any bits that need refreshing. In JUCE (and almost all modern UI frameworks), you never redraw something synchronously.

화면이 새로고침이 필요할 때마다 이 콜백이 호출된다고 한다. 화면에 직접 그림을 그릴 수 있는 Graphics  Context가 넘어오기 때문에 저기에 어떤 그림을 그릴 수 있는지는 Graphics 클래스를 들여다보면 알 수 있을것이라는 정보를 얻었다. 중요하게 짚고 넘어갈 점은 paint가 asyncronous하게 이루어진다는 점이다. Message Thread에서 이를 관리하기 때문에 paint 안에서 repaint를 호출한다고 해서 syncronous하게 동작하지는 않을 것이라고 말해주고 있다. 


virtual void Component::resized ( ) 

A component can implement this method to do things such as laying out its child components when its width or height changes. The method is called synchronously as a result of the setBounds or setSize methods, so repeatedly changing a components size will repeatedly call its resized method (unlike things like repainting, where multiple calls to repaint are coalesced together). If the component is a top-level window on the desktop, its size could also be changed by operating-system factors beyond the application's control.

이 콜백은 setBounds나 setSize 등의 메서드가 호출될 때 syncronous 하게 호출될 것이라고 한다. 만약 Component의 사이즈가 연속적으로 바뀌면 그에 따라 이 resize콜백도 동기적으로 함께 실행될 것이다.


객체지향의 기본오브 베이직이지만 한가지 짚고 넘어가자면, MainComponet는 Component를 상속받음으로써 Component에 구현되어 있는 모든 method를 사용할 수 있게 되었다. 하지만 paint와 resized라는 method에 한해서 MainComponet가 새롭게 이 동작을 재정의(override) 했다. Component 클래스에는 자식 클래스들이 새롭게 재정의해서 쓸 수 있는 method들이 아주 많이 있다. 필요에 따라 하나씩 찾아봐도 좋지만 미리 문서를 읽어보고 할 수 있는 것들이 뭐가 있는지 미리 파악해보는 것도 좋은 공부가 될 것이다.


3) MainComponent.cpp

/*
  ==============================================================================

    This file was auto-generated!

  ==============================================================================
*/

#include "MainComponent.h"

//==============================================================================
MainComponent::MainComponent()
{
    setSize (600, 400);
}

MainComponent::~MainComponent()
{
}

//==============================================================================
void MainComponent::paint (Graphics& g)
{
    // (Our component is opaque, so we must completely fill the background with a solid colour)
    g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId));

    g.setFont (Font (16.0f));
    g.setColour (Colours::white);
    g.drawText ("Hello World!", getLocalBounds(), Justification::centred, true);
}

void MainComponent::resized()
{
    // This is called when the MainComponent is resized.
    // If you add any child components, this is where you should
    // update their positions.
}


MainComponent.h에 선언된 것을 토대로 MainComponent.cpp에 구현되어 있는 정의들을 살펴보겠다. 주로 멤버의 초기화를 하는 생성자에서는 이 MainComponent의 사이즈를 설정해준다. 위에서 읽은 문서에 의하면 이 setSize가 불리면 이어서 resized 콜백이 동기적으로 실행된다고 했다. 하지만 지금은 resized에 아무 동작도 정의되어 있지 않고, setSize가 유일한 명령이기 때문에 생성자가 할 일은 끝났다. paint methd의 내용이 눈길을 끄는데 한 줄씩 살펴보면 화면의 중간에 "Hello Wold!"라는 텍스트를 띄우는 코드인 것을 알 수 있다. 함수 인자로 받은 Graphics 객체는 일종의 그림을 그리기 위한 Context혹은 Handle이라고 할 수 있다. 문서에 의하면 이 Graphics Class는 자신의 좌표 공간에 원, 사각형, 선, 텍스트 등을 그리기 위한 함수와 Affine Transform등의 변환 함수 등을 제공한다. 다시 말해 화면에 무언가 그럴싸한 것을 그리고 싶다면, 이 Graphics 클래스를 이용해 무엇이든 할 수 있다. 


빙글빙글 돌아가는 큐브를 그려보았다.


말하자면, Graphics 클래스의 drawLine method만으로도 이런 그림을 그릴 수 있다는 뜻이다. 물론 회전연산을 하고 Animation을 주는데는 몇 줄의 코드가 더 들어갔지만, 3차원 공간의 모습을 이렇게 2차원에 구현하는 것이 가능하다는 뜻이다. 3D그래픽을 그리기 위해 바로 OpenGL로 넘어가기 보다는 이렇게 2차원 화면에 그림 그리는 것을 몇 번 연습해보면서 좌표공간을 구현하는 것에 익숙해지는 시간을 가지고자 한다. 어느 정도 감각이 생긴 다음부터는 OpenGL로 넘어가도 금방 익숙해질 것이다. 

따라서 앞으로 본격적인 구현에 들어가기 전에 몇 편 동안에는 3차원 공간에 대한 기본적인 감각을 익혀볼 수 있는 시간을 가지려고 한다. 하지만 여기에 필요한 수학 지식은 오로지 삼각함수와 행렬 뿐이다. 증명을 위해서는 좀 더 고차원의 수학에 잠시 다녀올 수도 있겠지만 구현에 큰 영향을 줄 일은 없으니 안심해도 좋다.


시작에 앞서 앞으로 사용하게 될 JUCE Framework(이하 JUCE)에 대해 간단히 살펴보는 시간을 가지겠다. JUCE는 ROLI라는 영국의 악기 만드는 회사가 소유권을 갖고 있고, Julian Storer라는 개발자가 리드 프로그래머로 있는 C++ Framework이다. 기본적으로 Audio Programming에 특화되어 있으며 각종 UI Component들과 내부적으로 잘 짜여진 데이터 구조를 지원한다. 또한 기본적으로 여러 OS에서 동작할 수 있도록 크로스플랫폼 빌드를 지원한다. 


현재도 활발히 업데이트가 되고 있는데 요즘은 업데이트 속도도 점점 빨라지는 것 같다. 나는 지금까지 만 2년 정도 이 Framework을 사용했는데 처음 접했을 당시 4.1버전이었고 지금은 벌써 5.3 버전까지 올라와있다. ROLI에서는 JUCE를 널리 알리기 위해 오디오 개발자 컨퍼런스도 ADC라는 이름으로 매년 런던에서 개최하고 있다.


ROLI의 대표 악기인 Seaboard는 라라랜드에서도 라이언 고슬링이 연주하는 장면이 등장한다. ADC2017에 갔었을 때 만져볼 기회가 있었는데 생각보다 세게 눌러야 하고, 건반을 두드리는 것보다는 문지르듯이 연주해야 한다.


JUCE는 일단 기본적으로 Audio Programming을 위한 여러가지 강력한 기능들을 제공한다. Class 몇 개를 간단히 조합하면 단순한 Audio Player도 금방 만들 수 있고 Sample 단위로도 신호처리 알고리즘을 바로 적용해볼 수 있도록 설계 되어 있어서 DSP를 공부하는 C++ 개발자들에게 매우 훌륭한 Framework이다. 또한 같은 코드로 여러 타입의 Audio Plugin을 개발할 수 있도록 Wrapper 역시 제공하는데, 이 덕분에 여러 Plugin 회사들이 JUCE를 이용해서 VST, AAX, AU 타입 제품을 빠른 시간 내에 개발할 수 있게 되었다. 


대표적인 DAW중 하나인 Pro Tools와 그 위에서 동작하는 Plugin의 모습이다. 는 우리 회사 제품


JUCE가 멋진 또 하나의 이유는 바로 기본적으로 크로스플랫폼 빌드가 된다는 점이다. JUCE의 코드를 깊이 살펴보면 빌드 환경의 OS를 감지하여 Native UI를 지원하도록 만들어져 있다. 이를 통해 Plugin의 UI를 개발하는 시간 또한 엄청나게 단축시켜준다. 지원하는 OS는 Windows / Mac OS X / Linux는 기본이고 Android와 iOS 역시 지원한다. 하지만 모바일의 경우는 Native UI 컴포넌트를 사용하지는 못하고 JUCE Framework의 컴포넌트를 사용해야 한다. 


이미 크로스플랫폼 Framework의 세계에서는 Qt가 최강자로 군림하고 있지만 그렇다고 JUCE가 마냥 하위호환인 것만은 아니다. Audio Plugin 개발에 있어서는 JUCE를 따라올 만큼 편리한 Framework가없고, GUI 개발 기능 역시 QtCreator 못지 않게 강력하다. 또한 사용자가 원하는 IDE로 프로젝트를 자동으로 생성해주는데, 한 번에 여러 플랫폼의 프로젝트를 관리할 수 있기 때문에 크로스플랫폼 개발의 관점에서도 상당히 편리하다. 덕분에 지금 쓰고 있는 튜토리얼 코드도 회사의 Mac OS와 집의 Windows에서 모두 동일하게 동작한다.



같은 코드로 Ubuntu, Windows, Mac OS의 App을 동시에 개발한다.


이번 튜토리얼에서는 이 JUCE를 이용해서 UI윈도우를 가진 실행파일을 빠르게 만들어 볼 것이다. 그리고 하나의 JUCE 프로젝트 파일만으로도 여러 플랫폼에서 빌드할 수 있는 프로젝트 파일들이 만들어지는 것을 확인해보도록 하겠다.


1) JUCE 설치


JUCE는 기본적으로 무료 사용이 가능하다. 다만 실행 시 윈도우 오른쪽 하단에 잠깐 워터마크가 떴다가 사라진다는 차이만 있고 기능에 제약이 생기는 것은 없다. 다운로드는 아래 링크에서 받을 수 있다.



다운로드 받은 폴더를 열어보면 이런 구조로 되어있을 것이다. DemoRunner.exe를 잠깐 실행시켜보도록 할까.


여러 DSP 예제들이 준비되어 있다.

예전엔 없었는데 SIMD 예제가 추가됐다! 나중에 꼭 써봐야지

OpenGL 예제도 있다. 하지만 GLSL은 제일 낮은 버전이다.



예전엔 Demo도 상당히 빈약한 편이었는데 이제는 종류가 엄청 늘어났다. 이 데모들만 다 이해해도 JUCE를 전부 이해했다고 할 수 있을 정도로 잘 갖춰져 있다. 나중엔 TensorFlow Wrapper도 만들어지지 않을까 내심 기대해본다.



2) Project 생성


이번에는 Projucer.exe를 실행한다. 생성할 수 있는 프로젝트의 종류가 다양한데, 일종의 템플릿 형태기 때문에 꼭 이것을 통해서만 만들 수 있는 것은 아니다. GUI Application 프로젝트를 생성하더라도 설정을 바꾸거나 클래스를 상속받는 방법 등으로 얼마든지 Audio Application이나 다른 종류의 Application을 개발할 수 있다. 자세한 설명은 다음으로 미루고 오늘은 가장 첫 번째에 있는 GUI Application을 클릭해 보겠다.



오른쪽에 보이듯이, 다양한 IDE를 지원한다. 여러 개를 클릭할 수도 있어서 Windows에서도 Xcode project의 설정을 관리할 수 있다. Visual Studio는 2017까지 지원하고. Xcode도 9.1에서 문제 없이 돌아가는 것을 확인했다. 나머지는 잘 모르겠지만 잘 되겠지 ㅎ 적절한 프로젝트 이름을 붙여주고 다음으로 넘어간다.


아앗 시작부터 에러가


이런 메세지가 뜰 수 있는데, 프로젝트 설정창 오른쪽 상단에 있는 JUCE module의 경로가 잘못되어서 그렇다. 위에 있는 폴더 목록 사진을 보면 modules라는 폴더가 있다. 해당 경로를 Modules Folder로 지정해주면 에러가 사라진다.


조그마한 IDE처럼 생겼다. 실제로 간단한 코드 편집은 여기서 해도 될 정도로 편리하다.


자동으로 생성된 C++ 코드가 벌써 세 개나 있다. 천천히 뜯어보는 것은 나중으로 천천히 미뤄두고 일단은 상단에 있는 Visual Studio 아이콘을 세게 눌러보자.


몇 번의 클릭만으로도 벌써 근사한 프로젝트 솔루션 파일이 만들어져서 기분이 좋다. 어서 빨리 F6를 눌러 빌드가 잘 되는지 확인해보자.


1>------ 빌드 시작: 프로젝트: JUCEPractice1_App, 구성: Debug x64 ------
1>C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\Common7\IDE\VC\VCTargets\Platforms\x64\PlatformToolsets\v141\Toolset.targets(36,5): error MSB8036: Windows SDK 버전 10.0.16299.0을(를) 찾을 수 없습니다. 필요한 버전의 Windows SDK를 설치하거나, 솔루션을 마우스 오른쪽 단추로 클릭하고 [솔루션 대상 변경]을 선택하거나 프로젝트 속성 페이지에서 SDK 버전을 변경하세요.
1>"JUCEPractice1_App.vcxproj" 프로젝트를 빌드했습니다. - 실패
========== 빌드: 성공 0, 실패 1, 최신 0, 생략 0 ==========


하지만 시작부터 운이 좋지는 않았다. 이런 에러 메세지가 떴는데 아무래도 Windows SDK의 버전 문제인 것 같다. Visual Studio에서도 해결할 수 있지만 JUCE의 Project 설정을 통해 해결하면, 이후에도 지속적으로 적용시킬 수 있다. 우선은 현재 이 컴퓨터에 설치되어 있는 Windows SDK의 버전을 확인할 필요가 있다. 우선 현재 솔루션을 오른쪽 클릭해보자.



프로젝트 대상 변경, 영어로는 Project Daesang Byunkyeong...이 아니고 Retarget SDK Version이라는 항목이 있다. 얼른 클릭해보자.




그러면 현재 이 프로젝트에서 사용하고 있는 Windows SDK의 버전이 10.0.17134.0이라는 사실을 알 수 있다. 이 숫자를 외워둔 상태에서 아까 처음에 만들었었던 Juce 프로젝트 파일로 다시 가보자.



왼쪽의 메뉴에서 Exporters > Visual Studio 2017을 클릭하면 해당 솔루션 파일의 설정 값들 리스트가 나오는데, 아래로 스크롤 해보면 Windows Target Platform이라는 항목이 있다. 왠지 익숙한 듯 하지만 낯선 숫자가 적혀있다. 저 부분에 아까 확인했던 SDK 버전을 입력해주자. Visual Studio에 익숙한 사람이라면 위에 있는 Platform Toolset에도 자연스레 눈이 갈텐데, 저것을 다른 것으로 수정해주면 더 낮은 버전의 msbuild로도 빌드가 가능하도록 설정할 수 있다. 이런저런 설정들이 많으니 궁금하다면 하나씩 건드려보면 좋다. 





바꾼 설정을 저장하고, 다시 Visual Studio로 돌아가보면 다음과 같은 알림이 뜬다. 솔루션 다시 로드를 눌러서 통째로 Refresh를 해주자. JUCE 프로젝트에서 단순히 코드만을 수정하는 경우는 솔루션을 통째로 다시 로드 할 필요 없이 '다시 로드'만 눌러줘도 된다. 이제 빌드가 성공적으로 완료되는 것을 확인할 수 있다. 어서 빨리 F5를 눌러 실행을 시켜보자.




아, 언제 봐도 설레는 문장! 안녕!


코드는 한 줄도 치지 않고도 실제로 실행되는 윈도우를 만드는데 성공했다. 긴 천리길에서 이제 한 걸음을 내딛었지만 내심 뿌듯하다. 


3) 크로스 플랫폼 빌드


또 다시 몇 번의 클릭으로 이번에는 같은 프로그램을 Mac OS 버전으로 만들어 보겠다. 다시 Projucer로 돌아가자. 왼쪽 메뉴의 Exports 항목을 클릭한 뒤 빈 공간을 오른쪽 클릭하면 프로젝트를 생성할 수 있느 IDE의 목록이 나온다. 여기서 Xcode(MacOSX) 항목을 선택한다.


다 해줄 줄 알았지만 안 되는건 안 되는거다.

혹시나 하는 기대에 부풀었을지도 모르겠지만 애석하게도 JUCE가 Windows에서 Mac OS App 빌드까지 지원해주지는 않는다. 사실 애초에 불가능 한 일이기도 하다. Windows에서 할 수 있는건 다만 Xcode 프로젝트 설정을 추가하고 저장하는 것 뿐이다. 이제 이렇게 만들어진 Projucer파일을 Mac OS로 옮겨야 한다. 프로젝트를 생성한 폴더를 보면 *.jucer라는 확장자의 파일이 있다. 이것이 JUCE의 프로젝트 파일이다. 프로젝트 파일 뿐만 아니라 소스코드까지 모두 옮겨야 하기 때문에 jucer 파일이 있는 폴더를 통째로 압축하자.


미리 준비해뒀던 맥북을 꺼내 아까 압축했던 파일을 USB에 담아 원하는 위치에 옮긴다. 아 물론 Mac OS에도 Projucer가 설치되어 있어야 한다는걸 잊지 말자. 설치 과정은 Windows와 정확히 같다.


Projucer를 열어보자. UI의 LookAndFeel이 Windows와 완전히 같은 모양으로 만들어져 있다. 별거 아닌 것 같지만 감동스러운 부분이다. 이번에는 상단의 Xcode 버튼이 활성화 되어있다! 세게 눌러주자.



전체적인 파일 구조 역시 Visual Studio에서 열었을때와 동일하다. Same Code Different Platform이 매우 잘 구현되어 있음을 알 수 있다. 왼쪽 상단의 화살표 버튼을 눌러 빌드 후 실행을 해보자.


또 만났네 안녕!

이 과정까지도 여전히 코드는 한 줄도 치지 않고 왔지만. Mac OS에서도 문제없이 잘 실행된다! 이제 본격적으로 코드를 작성할 준비가 갖춰졌다. 다음편에서는 이렇게 만들어진 어디서든 인사를 건네는 다정한 프로그램의 내부를 좀 더 살펴보면서 JUCE Framework의 구조에 대해 소개하는 시간을 갖도록 할 것이다.


아마 고3 때였던 것으로 기억한다. 수학 시간에 어느 벡터에든 곱하기만 하면 원점을 기준으로 원하는 각도만큼 회전시켜주는 신기한 행렬을 배웠었다. 그 당시 지루한 수험생활을 견디기 위해 이것저것 코딩을 하곤 했었는데, 이 회전행렬을 배운 날 바로 간단하게 화면 위에 임의의 점을 그리고 XYZ축 회전을 시켜볼 수 있는 프로그램을 짰다. 아래엔 그 7년 전 코드에서 발췌한 공간 위의 점을 X축 Y축 Z축 순서대로 회전하는 코드이다. 



ty = (t.y) * Math.Cos(ax) + (t.z) * Math.Sin(ax);
tz = -(t.y) * Math.Sin(ax) + (t.z) * Math.Cos(ax);
tx = t.x;
t = new spacePoint(tx, ty, tz);

tx = (t.x) * Math.Cos(ay) + (t.z) * Math.Sin(ay);
tz = -(t.x) * Math.Sin(ay) + (t.z) * Math.Cos(ay);
ty = t.y;
t = new spacePoint(tx, ty, tz);

tx = (t.x) * Math.Cos(az) + (t.y) * Math.Sin(az);
ty = -(t.x) * Math.Sin(az) + (t.y) * Math.Cos(az);
tz = t.z;
t = new spacePoint(tx, ty, tz);

짧은 코드지만 결과가 상당히 재밌었다. 몇 줄의 코드로 2차원 평면에 3차원 공간을 그려지는 것을 직접 경험한 것이다. 이때는 Euler Angle의 개념도 알 턱이 없었지만 서로 다른 축으로 여러 번 회전 시킬 때 회전하는 순서가 다르면 결과도 달라진다는 사실을 깨닫고 꽤 신기해 했던 기억이 난다. 이때부터 재미가 붙어서 이것저것 재밌게 기능을 추가하면서 놀았던 것 같다. 코드를 여기저기 살펴보니 별 기능이 다 들어 가있다. 여러가지 객체를 추가하고 움직일 수 있도록 명령어를 만들어서는 화면 안에서 이리저리 물체들이 뛰어 놀게 하고 있다.

백업을 생활화 하면 7년 전 코드도 이렇게 실행해 볼 수 있다.


하지만 화면에서도 알 수 있듯이 표현 가능한 것은 면적이 없는 점과 선 뿐이다. 면을 표현하기 위해서는 어느 면이 화면이 비춰지고 어느 면이 뒤로 가려지는지를 판별해야 하는데, 그 때 알고 있던 평면방정식이나 다른 벡터기하학 지식을 아무리 조합해 짱구를 굴려봐도 도저히 마땅한 방법이 떠오르지 않았다.

프로그래밍 선생님께도 여쭤봤지만 "음... 그래픽카드를 이용해야 할거야."라는 정도의 대답 말고는 얻을 수 있는 것이 없었다. DirectX나 OpenGL따위의 고오급 기술을 새로 배우기에는.. 핑계였을 지는 몰라도 나는 그때 고3 이었다. 


지금의 관점으로 보면 '엔진'이라는 이름을 달기에도 민망하지만 그래도 내 이름을 따 [창훈그래픽스]라는 이름을 여기 기꺼이 붙여주고 이리저리 가지고 놀았던 추억 어린 코드다. 내가 OpenGL을 접한 것은 오래되지 않았지만, 독학으로도 쉽게 배우고 금방 시각적으로 무언가 보이는 것들을 개발할 수 있게 된 것도 이때의 경험들이 머릿속에 있기 때문이었을 것이다. 


따지고 보면 삼각함수는 빠르면 중학생 때도 배우고, 벡터기하학이나 선형대수학의 기본적인 내용들도 다 고등학생 때 배우기 때문에 그 정도의 지식만 갖고 있어도 3D 그래픽 구현에 필요한 수학은 다 깨우치지 않을까 하는 생각이 들었다. 그래서 이번 기회에 약간의 수학적 지식만 있다면 쉽게 3D 그래픽을 구현해볼 수 있도록 차근차근 그 과정을 연재해보고자 한다. 


어떻게 완결을 할지는 아직 떠오르지 않지만 꾸준히 연재하다 보면 무언가는 만들어져 있겠지. :)

+ Recent posts