필자는 쓸데없지만 재밌는 프로그램을 만들면서 공부하는 것을 좋아한다. 최근에 정신적으로 에너지가 바닥나고 있다는 것을 느껴 작년에 재미로 만들었던 Jazzlang과 같이 정신적 에너지를 채워줄만한 재미를 느끼고 싶었다. 그래서 무언가 할만한 것을 찾던 중 작년 이맘때 쯤 재밌는 유튜브 영상을 봤던 것이 생각났다. 영상에서는 CLI 환경에서 큐브를 회전시키는 프로그램을 처음부터 끝까지 조용히 코딩하는 것을 보여준다.

필자는 이 아이디어가 굉장히 참신하다 생각했다. 마침 무언가를 만들고 싶었던 차에 큐브가 됐다면 CLI 환경에서 다른 물체도 3D 렌더링 할 수 있지 않을까?라는 생각을 하게되었고 실제로 구현했다. 이 글의 결과물은 GitHub 저장소와 Chromatic에서 볼 수 있다.
어떻게 ASCII를 3D처럼 보이게 할까?
3D 렌더링은 빛과 그림자, 최적화, 쉐이더, 충돌 처리, 물리 계산 등 굉장히 난이도가 높은 기술이 포함된다. 하지만 우리는 단순히 3D 물체를 화면에 그리기만 할 것이기 때문에 몇 가지 기본적인 기술만 알면 된다. 우리는 화면에 나오는 물체를 왜 삼차원 공간으로 인식을 할까? 답은 간단하다. 스크린을 통해 원근감을 표현했기 때문이다. 사람이 원근감을 느끼도록 만드는 방법은 다양하지만 일반적인 CLI 환경에서 쓸 수 있는 것은 선1과 명암 뿐이다.

선을 이용하여 원근감을 표현하는 방법은 간단하다. 멀리있는 것은 결국 한 점으로 모인다는 사실을 이용할 수 있다. 이를 미술 용어로 소실점이라 부른다. 소실점은 여러 개가 있을 수 있다.

명암을 포함한 원근감을 표현하는 방법은 더욱 간단하다. 빛이 있는 곳은 밝게, 빛이 없는 곳은 어둡게 표현하면 된다. 이를 통해 물체의 입체감을 표현할 수 있다.
그런데 ASCII 만으로 원금감, 입체감을 표현할 수 있을까? 우선 결론부터 이야기하자면 ASCII를 이용한다는 것은 사실 조금 큰 픽셀이라 생각하면 된다. 다음 이미지를 보자.

좌측은 원본 이미지 우측은 Pixelate 이펙트를 입힌 이미지다. ASCII를 사용한다는 것은 단지 글자를 이용하여 우측 이미지 처럼 만드는 것과 크게 다르지 않다. 이를 이용한 작품도 존재한다.

위 이미지를 보면 텍스트만으로 충분히 이미지를 표현할 수 있다는 것을 알 수 있다. 그리고 빛이 보고있는 방향에서 나온다 가정했을 때 가까이 있을 수록 짙은 텍스트 멀리 있을 수록 옅은 텍스트로 나타냈기에 원근감과 명암마저도 표현할 수 있다.

참고로 간격이 일정해야 하므로 반드시 고정폭 글꼴을 사용해야 한다. 앞서 소개했던 유튜브 영상에서 나오는 큐브처럼 명암을 표현하면 다음과 같다.
위 예제는 필자가 유튜브 영상에 나온 프로그램을 직접 웹에서 실행 가능하도록 구현한 것이다. 빛에 기반한 명암 처리는 아니지만 각 면의 명암이 다르기에 입체감을 느낄 수 있다. 이제 이를 구현하기 위해 필요한 지식을 알아보자.
3D 좌표를 2D 좌표로
우리가 표현하고자 하는 물체는 3D 공간에 존재하지만 모니터 스크린은 2D 공간이다. 따라서 이를 표현하기 위해서는 값으로 정의된 3D 공간을 모니터 스크린인 2D 공간에 보이도록 변환시킬 필요가 있다. 이런 변환하는 과정을 렌더링 파이프라인이라고 한다.

이 글에서는 오로지 ASCII를 이용하여 3D 물체를 표현하는 것이 목표이므로 렌더링 파이프라인의 일부만 구현할 것이다. 그렇기 때문에 전체를 자세히 설명하지는 않고 필요한 부분만 설명할 것이다. 렌더링 파이프라인은 크게 보면 세 과정으로 이루어진다.
- 버텍스 처리 (Vertex processing)
- 래스터화 (Rasterization)
- 프래그먼트 처리 (Fragment processing)
각 과정을 하나씩 살펴보자.
버텍스 처리
3D 공간을 표현하기 위해서는 3D 공간 좌표가 필요하다. 3D 공간 좌표는 데카르트 좌표계를 사용하여 좌표를 3개의 축 (x, y, z)와 같이 표현한다. 어려운 용어처럼 느껴지지만 그냥 3개의 값으로 좌표를 표현한다고 생각하면 된다.

이렇게 표현된 좌표를 버텍스Vertex라고 부르며 3D 공간에 위치한 '정점'을 의미한다. 버텍스 하나로는 물체를 표현할 수 없으므로 버텍스를 모아 물체를 표현한다. 이때 물체를 표현하는 최소 단위를 폴리곤Polygon이라고 부른다. 보통 폴리곤은 특수한 경우2를 제외하면 3개의 버텍스로 이루어진 삼각형으로 표현한다. 하필 삼각형인 이유는 면을 구성하기 위해 필요한 최소 단위가 삼각형이며 효율적이기 때문이다.

이렇게 버텍스와 폴리곤으로 구성된 하나의 물체를 폴리곤 메시Polygon mesh라고 부른다. 보통 개발자는 디자이너가 만들어준 3D 모델 파일을 불러와서 폴리곤 메시를 만든다. 3D 모델 파일에는 버텍스와 폴리곤을 포함한 다양한 정보가 담겨있다. 이 정보를 통해 물체를 표현할 수 있다. 이 과정에서 버텍스를 변형하는 과정을 버텍스 처리Vertex processing라고 부른다.
버텍스 처리 단계에서 버텍스에 대한 여러 처리를 할 수 있지만 보통 변환을 처리하는 것이 가장 기본이다. 여기서 말하는 변환이 3D 공간을 2D 스크린으로 옮기는 핵심이다. 버텍스 처리 과정에서 변환은 보통 세 단계를 거친다.
- 월드 변환 (World transform)
- 뷰 변환 (View transform)
- 투영 변환 (Projection transform)
각 변환은 행렬Matrix을 이용하여 처리한다. 이제 각 변환에 대해 알아보자.
월드 변환
월드 변환은 3D 모델 파일에 담겨있는 버텍스를 월드라고 부르는 3D 공간에 배치하는 것이다. 3D 모델 파일은 자신만의 공간에서 고정된 값을 가지지만 월드에 배치할 위치나 크기, 회전 각도에 따라 새로운 값을 부여해줄 필요가 있다. 모델 좌표를 변경하여 월드에 배치하는 것이기 때문에 모델 변환이라 부르기도 한다.

앞서 설명한 것처럼 좌표 변환은 행렬을 이용하여 처리한다. 이때 변환을 처리하기 위한 행렬은 3차원 좌표를 처리함에도 불구하고 4x4 행렬을 사용한다. 각각 하나씩만 처리한다면 3x3 행렬을 사용해도 문제 없지만 이동, 크기, 회전을 한 번에 처리하기 위해서는 4x4 행렬을 사용해야 한다.3

이동, 크기, 회전에 대한 행렬이 존재하지만 지금은 우선 그런 것들이 있다는 것만 이해하고 넘어가자. 요약하자면 버텍스 위치에 대한 열벡터(1×4 행렬)와 각 행렬을 모두 곱하는 것이 월드 변환이다. 월드 변환을 마치고나면 불러온 모델을 월드에 절대적인 좌표로 배치했다고 볼 수 있다.