본문 바로가기

Function

많은 양의 개체들을 시각화하는 방법, 그리고 전국의 모든 건물

        

대표이미지




글 : 김승범 




공간 데이터를 시각화하다 보면 종종 양적인 문제에 맞닥뜨린다.


특수효과로 화면을 뭉개기보다는 좌표점을 입력하여 정직하고 명료하게 표현하는 일이 대부분이다. 간혹 '어선 경로'처럼 데이터에 없는 궤적들을 만들어서 그려야 할 경우에는 <Computational Geometry> 같은 책에 소개된 류의 알고리즘들을 알아야 한다.


그래서, 게임 개발자들처럼 다단계 매핑이나 특수효과에 신경 쓸 일은 별로 없고 건축투시도처럼 레이트레이싱으로 한참 계산해야 하는 작업들은 거의 없다. 다만, 한 가지 주로 발생하는 문제는 데이터의 양이다.



이 글에서는 차례대로 자바, 프로세싱, OpenGL을 사용하여 그렸던 절차에 대해 시행착오를 위주로 설명해보려 한다.


"많은 양의 데이터를 시각화 하려고 할 때 어떻게 해야 할까"에 대해 궁금해하는 사람이라면 도움이 될 것 같다. 물론 여기서 설명하는 방식이 하나의 정답은 아니다. 소통을 중요하게 생각한다면 웹 시각화를 배워야 하고, 연구 목적처럼 좀 더 빠르게 정해진 템플릿에서 데이터의 분포를 그려보고자 한다면  R이나 파이썬에서 제공하는 패키지를 사용하는게 좋다. 어떤 영역에서든, 목적이 달라지면 도구도 달라지기 마련이다.







Java로 6시간동안 그리기



3년 전에 아래의 그림, <26년간 서울의 공시지가 변동>을 그리는 작업을 할때 130만개의 선을 그려야 했다.





당시에 사용할 줄 아는 언어는 자바와 자바스크립트 두 가지.


자바스크립트에서는 d3.js를 주로 이용했는데, svg 기반이라 선이 만개 정도만 되어도 작업이 어려워졌다. 당시에는 canvas나 WebGL의 존재를 몰랐기 때문에 자바스크립트에서 그리는 것은 불가능하다고 판단했다.


자바에서는 데이터 처리만 하고 그래픽을 전혀 다루지 않았었는데, "자바스크립트에서는 안되니까 자바에서 어떻게든 그려보자"는 이상한 논리로 작업을 시작하게 되었다.


이 작업과정은 아래의 글에서 상세히 설명한 바 있다.

https://www.vw-lab.com/30


JFreeChart라는 라이브러리로 그렸는데, 공시지가 선이 3만개(하나의 선은 100여개의 line들로 이루어졌으므로 실제로는 300만개)가 넘으면 파일을 jpg나 pdf로 출력해내는데 너무 오래 걸렸다. 결국 전체 130만개를 40여개 그룹으로 분할한 후 반복 렌더링하여 저장했다. 그 결과물인 40여장의 하나하나씩 이미지를 합성하여 최종 이미지를 얻을 수 있었다.


[한 장 한 장의 이미지를 확대하면 이러하다]



시행착오를 거쳐서 꽤 오랜 시간이 걸렸는데, 한 사이클이 너무 오래 걸리기 때문에 최종 결과물을 확인하고 코드를 수정한 횟수는 4-5번 정도밖에 안 되는 것 같다. 모든 것이 결정된 동일한 작업을 다시 하라고 해도 6시간 정도는 족히 걸릴 것 같다.


그 때는 그게 최선의 방법이라고 생각했는데 지금 와서 보면 살짝 부끄러워진다. 잘 몰라서 고생한 과정을 그렇게 상세히 기록해두다니.... 이제 와서 지울 수도 없고, 행여나 여기 와서 그 글만 보고 간 후 '130만개의 선을 그리려면 이렇게 해야 하는구나'하고 생각할 사람들이 있을까 우려도 된다.







프로세싱으로 100초만에 그리기



그러다가 우연히 프로세싱(Processing)을 알게 되었다. OpenGL을 자바와 묶어준 JoGL을 잘 포장해놓은 (일종의) 언어다. 외형은 단순해 보이지만, 결국 자바 코드에서 머리와 꼬리를 잘라놓은 격이라 약간의 규칙만 지켜주면 자바의 문법이 그대로 통한다. 물론 약간의 제약이 있기 때문에 이게 답답한 사람들은 프로세싱 포장을 뜯어내고 자바로 프로세싱 라이브러리를 가져와서 사용하기도 한다.


어찌되었든 OpenGL을 이용하기 때문에, 상당히 빠르다. 시각화에 필요한 함수들을 잘 준비해두었고, 문법이 간결하기 때문에 개발자가 아닌 사람들도 많이 사용한다.


데이터에서 읽어들인 몇 만개의 선 정도는 1초에 60프레임 정도로 그려낼 수 있다. 물론 단순한 선인지, 혹은 여러가지 처리를 많이 한 것인지에 따라 달라지겠지만.

데이터에서 읽어들이지 않고 임의로 발생시켜서 shader에서 직접 처리할 경우 백만개가 넘는 선들도 무리없이 그려낸다. (직접 해보지는 못했다)


아래의 인구이동 시각화는 Processing으로 그렸다.

580만개의 인구이동 곡선이고 하나의 곡선은 몇 백개의 직선들로 분할되었기 때문에, 결국 2억개에 가까운 line들을 그리는 작업이었다.



[2015년 1년간 전국의 모든 행정동별 인구 이동]





한 장 그리는데 100초 정도 걸렸는데, 자바에서 6시간동안 그렸던 일을 생각하면 정말 감지덕지할만한 시간이었다. 100초에 한장을 그려볼 수 있으므로 여러번의 시행착오를 거쳐 결과물을 완성할 수 있었다.








프로세싱의 드로잉 방식은 자원을 충분히 활용하지 못했다


그런데 여기서 또 한번 양적인 문제에 봉착했다.


아래의 작업은 프로세싱에서 했다. 직육면체 수십만개가 있었고, 그렇게 복잡한 작업이 아니라고 판단했는데 한 장 그리는데 1초가 넘게 걸렸다. 인구이동처럼 한 장의 이미지를 그리는게 아니라, 4000장쯤 그려서 영상으로 만들어야 했했기 때문에 시간이 꽤 크리티컬한 문제였다. 당시에는 GTX 1060 을 사용하고 있었는데 일단은 약간씩 참아가면서 작업을 마무리했다. 최종 렌더링은 두시간 정도 걸렸던 것 같다.







작업이 끝난 후, 성능 문제를 해결해야겠다는 생각이 들어 일단 그래픽카드를 GTX 1080 Ti 로 교체해 보았다. 안타깝게도 성능 향상이 별로 없었는데, 의아했던건 윈도우 작업관리자에서 보여주는 GPU 이용률이 50% 정도에서 더 올라가지 못한다는 부분이었다.


그런데 그 때쯤 우연히 kepler.gl을 알게 되었다. 우버에서 공개한 오픈소스 라이브러리인데 WebGL을 기반으로 만들었다. 웹 브라우저 상에서도 몇 가지 데이터로 테스트해 볼 수 있다. 그런데 간단한 데이터 몇개를 얹어서 돌리다보니 프로세싱에서 힘들어했던 작업과 비슷한 정도의 복잡도를 지닌 시각화가 GTX 1060 보다 한참 낮은 사양의 GTX 750에서도 팽팽 돌아가는 걸 목격했다. 그것도 웹 브라우저에서.




[kepler.gl 사이트의 소개 이미지. 이것보다 훨씬 많은 입체도형이 저사양 pc에서 실시간으로 잘 돌아간다]




그때야 비로소 '아, 내가 크게 모르는 부분이 있구나'라는 생각이 들어 이것저것 열심히 찾기 시작했다.


어찌어찌 검색을 통해, 프로세싱은 OpenGL에서 물체를 그리는 방법 중 가장 낮은 1.0 버젼의 방식, 그러니까 모든 선들을 하나씩 cpu에서 gpu로 보내는 방식을 사용한다는걸 알게 되었다.


우리가 컴퓨터를 사용할 때는 어떤 경우든간에 cpu에서 작업을 한다고 보면 된다. gpu 컴퓨팅을 한다고 해도 그 시작과 끝은 cpu로 돌아오게 된다. 꽤 많은 경우에서의 작업은, cpu에서 gpu로 데이터 묶음과 코드를 함께 전달해 주면 gpu에서 알아서 처리를 한 후에 결과물을 cpu로 되돌려주는 방식으로 이루어진다.


Cuda같은 순수 연산 작업 말고, 화면 출력을 전제로 하는 그래픽 작업에서 cpu가 gpu에 그리라는 신호를 주는 것을 일반적으로 'draw call'이라고 부른다. 그런데, 만약 프로세싱에서 10000개의 개체를 그린다면 프로세싱은 10000번의 draw call을 한다. 

모든 개체의 draw call을 한다는 건 사람의 일로 비유하자면, 트럭에서 물건을 나르는 일을 시킬 때 과정을 잘 설명해 준 후, '그럼 알아서 하세요'라고 말하지 않고 '트럭 앞으로 가, 손을 뻗어, 상자를 들어, 뒤돌아, 여기로 와, 내려'라고 말하는 방식이다. 


당연히 효율이 떨어지기 마련이다. 그렇기 때문에 세 배가 넘는 연산 코어를 가지고 있음에도 불구하고 GTX 1080 Ti는 1060보다 그리 빠르지 않은 성능을 보여주었던 것이다. cpu와 gpu 사이의 통신에서 병목현상이 걸렸다.





'HELLo world' in OpenGL


결국 이 문제를 근본적으로 해결하기 위해서는 DirectX, OpenGL, Vulkan 같은 저수준의 라이브러리를 사용해야만 했다. 어쩌겠나. 일단 칼을 뽑았으니 썰어야지.


내가 선택한 것은 OpenGL이었다. 향후에 사용하게 될지도 모를 WebGL을 염두에 둔 선택이었다.

OpenGL은  C/C++에서 사용할 수 있는 라이브러리다.

......

어쩌겠나. 일단 칼을 뽑았으니 C부터 썰어야지.


C나 C++부터 배운 사람들은 그저 다른 언어들이 헐렁해보일 뿐이겠지만, 자바를 먼저 배우고 C/C++를 배우려니 여간 불편하고 여러운게 아니었다. 겨우겨우 그럭저럭 문법들을 익힌 후에 OpenGL을 시작했는데, 이 역시 총체적 혼돈이었다. 

한참 배우고 나서야 알게 된 사실이지만, OpenGL은 버젼업 하는 동안 완전 누더기가 되어버렸다는 공감대가 있다. 거의 같은 작업을 하는 여러개의 명령어가 동시에 존재하는데, 예전 것들도 하위 호환성을 위해 남겨두었다. 게다가 어떤 작업을 하기 위해서는 서너개의 함수들을 빠짐 없이 호출해야 하는데 이 중 하나에 다른 버젼의 함수를 섞어서 사용하면 작동하지 않는다. 그 묶음이 어떤 것들인지 명료하게 정리해주는 문서도 찾기 어렵기 때문에 배우는 입장에서 굉장히 헤맬 수 밖에 없다.


그래서 하드웨어 제조업체에서도 요새의 하드웨어 성능에 잘 맞추어 새롭게 설계한 라이브러리인 Vulkan에 주력하는 모양새다. OpenGL을 계속 버젼업 할 것이라고 했지만, 애플에서도 OpenGL을 더 이상 지원하지 않겠다고 선언했고, 엔비디아에서 새로 개발해서 공개하는 API들에도 OpenGL이 슬쩍 빠지고 Vulkan과 DirectX만 포함시키는 경우가 종종 있다.







생산에서 경험으로 : OpenGL로 3000배 빠르게 그리기


어찌되었든 몇 달에 시간에 걸쳐 C/C++와 OpenGL에서 필요한 부분들을 익혔고, 겨우겨우 데이터들을 그림으로 바꿀 수 있게 되었다. 아래의 영상은 배우면서 연습삼아 프로세싱에서 했던 인구이동선을 OpenGL에서 3차원으로 그려본 것이다.






580만개 곡선을 그리는데, 한프레임에 0.03초. 프로세싱보다 3000배쯤 빨라졌다.


작업시간이 자바 6시간에서 프로세싱 100초로 줄어들었을 때에는 보다 짧은 시행착오 사이클을 통해 결과물을 세련되게 만들어나갈 수 있다는 장점이 있었다. 그런데 0.03초 이하로, 즉 1초에 30프레임을 그릴 수 있는 한계점을 넘게 되면 새로운 가능성이 열린다. 실시간 인터랙티브 경험이 가능해진다. 어느정도의 시간 단축이 생산을 효율적으로 만들어 주었다면, 임계점을 넘는 순간, 속도는 생산이 아닌 경험의 차원과 새롭게 연관이 된다. 


아래 링크는 서울 생활인구를 격자 단위로 시각화 한 영상이다.  위에서 예로 들었던, 프로세싱에서 작업한 혁신도시 인구와 유사한 정도의 작업인데, 같은 컴퓨팅 환경에서 프로세싱보다 60배 정도 빠르다. CPU에서 하는 일은 거의 없기 때문에 CPU + GPU의 조합이 i5-2500 + GTX 1080 Ti 인 경우나, i7-7700k + GTX 1080 Ti인 경우나 비슷하다.



<서울시 생활인구 시각화>







OpenGL을 사용하면 데이터들을 병렬적으로 처리하여 빠르게 그릴 수 있다


OpenGL의 작업 방식은 이러하다. 우선 데이터들을 그래픽카드의 메모리(vram)으로 복사해 놓으면, 매 프레임을 그릴 때 마다 반복적으로 이 데이터를 읽어서 순차적인 파이프라인에 통과시켜 처리를 한 뒤, 마지막에 화면상의 픽셀로 출력을 하게 된다. 파이프라인에 대해 말하자면 GTX 1080Ti의 경우, 3840개의 쿠다코어가 나누어 동시에 처리를 하는 방식이다. 

[Processing 과 OpenGL의 작업 방식 비교]



위 그림에서 Processing과 OpenGL의 작업흐름을 간단히 비교했다. Processing은 매 프레임마다 점이나 선과 같은 기본 primitive 들을 개별적인 draw call에 넣어 순차적으로 gpu에 보낸다. gpu는 병렬처리하지만 작업 대기열에 들어오는 데이터의 양에 한계가 있으므로 작업이 빠르지 못하다. 사용자는 video memory 에 직접 접근할 수 없기 때문에 cpu 측에서 하나씩의 draw call을 보내는 수 밖에 없다.


반면 OpenGL의 경우에는 미리 video memory로 데이터를 복사해 놓은 후 한번의 draw call을 보낸다. 메모리에 동시다발적으로 접근해서 데이터를 읽고 처리하기 때문에 훨씬 빠르다.



그리고 데이터들이 통과하는 파이프라인 중 일정 부분에 사용자가 개입하여 코드를 작성하게 되는데, 처리 순서대로 말해보자면 버텍스 쉐이더(Vertex Shader), 지오메트리 쉐이더(Geometry shader), 프래그먼트 쉐이더(Fragment Shader)와 같은 것들이 있다.


버텍스 쉐이더에서는 입력되는 점 하나하나에 대해서 계산을 할 수 있는데, 생략할 수도 있는 다음 단계인 지오메트리 쉐이더에서는 입력되는 점들을 삼각형 단위로 묶어서 처리하거나, 역으로 점 하나를 받아와서 삼각형을 발생시킬 수도 있다. 여기까지가 벡터 데이터를 다루는 것이라고 할 때, 마지막 프래그먼트 쉐이더는 래스터라이징 단계로서 화면의 픽셀들을 기준으로 삼각형 안에 어떤 색을 칠할 것인지를 계산해낸다.


만약 게임이라면 화면 상에 존재하는 다양한 캐릭터나 지형지물들이 서로 다른 변수에 그룹화 되어 있을 것이고, 이를 그리려면 매 프레임마다 무수히 많은 draw call을 보내야 할 것 같다(실제로 어떻게 하는지는 잘 모르겠다). 데이터 시각화에서는 매 프레임마다 몇 번만 drawcall을 보내게 되는데, 그래서 같은 성격의 데이터로 다수의 개체들을 독립적으로 그리는 작업에 최적화 되어 있는 OpenGL과 궁합이 아주 잘 맞는다.




그런데 OpenGL로 그리려면 모든 작업을 백지에서 출발해야 한다는 각오를 단단히 해야 한다.


예를 들어,


글자를 쓰려면 폰트 파일을 읽어서 폰트 파일 안의 데이터를 해석해야 한다. 가운데 정렬을 하려면 서로 다른 각 글자들의 가로 너비를 모두 더한 후 절반으로 나누는 작업을 해야 한다. 알파벳의 경우 글자의 가로 크기는 같은 폰트라도 글자마다 다르기 때문이다.


3차원 공간을 바라보는 화면에서 zoom/pan/rotate등의 조작을 추가하려면 행렬 곱셈을 통해 직접 구현해주어야 한다.

투명한 개체 여러개를 자연스럽게 보이도록 겹쳐 놓으려면 OIT(Order Independent Transparency)라는 개념을 익히고 쉐이더에서 직접 코드를 작성해야 한다. 


그 밖에도 무궁무진하다. 


물론 라이브러리가 전혀 없는건 아니지만 기본적인 기능들은 대부분 직접 구현해주어야 한다.


그래도 OpenGL 자체의 지속가능성에 대한 논의를 잠시 잊는다면, 이것보다 더 빠른 방식은 없다는 '안심'속에 자체에만 집중할 수 있다는 희한한 장점도 있다. 프로세싱에 한참 시간 투자를 하다가 OpenGL으로 갈아타게 된 시행착오를 생각하면 당분간 더 이상 그런 시행착오는 없을 것이라는 '안심'말이다






OpenGL 을 사용하더라도 어떻게 쓰느냐에 따라 성능 차이가 크게 벌어진다



물론 여기가 끝은 아니다. OpenGL에서도 어떻게 구현하느냐에 따라 열배가 넘는 성능 차이가 벌어진다.


아까 위에서 봤던 3차원 곡선 인구이동선의 경우에도 OpenGL에서 그리는 방법을 세 가지 정도 생각해볼 수 있다.

참고로, 이 곡선들은 방향과 크기는 모두 다르지만, 회전확대축소를 하면 결국 동일한 한가지 형태로 수렴된다. 전체 진행률에 따른 곡률 변화 정도가 똑같다는 말이다. 


세가지 방법은 다음과 같다.


① 580만개 곡선에 대해서 각 곡선을 이루는 세부 직선들의 좌표를 모두 미리 계산해서 메모리에 넣고 단순히 좌표를 읽어서 그리는 방법. 중간 좌표들을 모두 저장해야 하므로 많은 양의 메모리를 필요로 한다.


② 출발점과 도착점만을 메모리에 넣은 뒤, 쉐이더에서 공식에 따라 모든 곡선마다 중간 좌표들을 생성하여 곡선으로 만드는 방법. 모든 프레임마다, 모든 곡선마다 곡선 좌표를 반복해서 계산해야 한다.


③ 0~1 사이의 단위 공간 안에 곡선 하나의 좌표들을 넣은 뒤, 이 곡선 하나의 좌표와 출발도착점을 메모리에 넣어 전달한 뒤, 쉐이더에서 그 곡선의 양 끝이 출발점과 도착점에 맞게 확대 및 회전해서 그리는 방법. 한 곡선의 복사본(instances)들을 만든다고 생각하면 된다. 모든 프레임, 모든 곡선마다 반복해서 확대회전을 해야 한다.


얼핏 보기에 그냥 계산을 미리 해두고 단순히 읽어서 옮겨 그리는게 가장 빠를 것 같다. 그런데 실제로는 1번의 방법이 가장 느리다. 거의 1년 전이라 정확히 기억은 안나는데 1/2의 속도가 나왔던 것 같다. 2번과 3번은 비슷하다. 


그래픽 카드 안에도 VRAM/캐시 메모리/레지스터가 있는데, VRAM의 경우에도 충분히 빠르지만 캐시 메모리는 두 배 이상, 그리고 레지스터는 더 빠르다. (솔직히 말하자면, 얼마나 차이가 나는지는 아직 잘 모르겠다. 코드 최적화를 위해서는 메모리 대기시간까지 신경쓰면서 처리 순서를 배열해야 한다는건 알고 있는데, 아직 그걸 어떻게 해야 하는지는 모른다.)


다시 580만개 곡선 얘기를 하자면, VRAM에서 30개의 좌표를 읽는 시간(1번의 경우)보다 출발도착 2개 좌표만 읽은 후 열 몇 줄 이상 되는 계산을 하는게 훨씬 빠르다(2번 3번의 경우). 계산할때는 연산 코어 바로 옆에 붙어 있는 레지스터나 약간 더 떨어져 있는 캐시 메모리를 사용하기 때문이다.






전국 1400만개의 건물을 모두 그릴 수 있을까?


어느 날, 시각화에 사용할 배경이었던 서울의 건물들을 높이만 extrude시킨 단순한 형태로 화면 안에 옮겨 그리는 작업을 하다가 한가지 호기심이 생겼다. 전국의 모든 건물들을 GTX 1080 Ti 환경에서 그릴 수 있을까? 어차피 서울 건물을 그리는 과정을 작성했기 때문에 거기에 루프구문을 한 줄 추가해서 폴더 안의 파일을 모두 읽어 버리면 그만이었다.


국가 승인 통계에 따르면 전국의 모든 건물은 약 700만동이다. 국토부에서 공개하는 GIS건물통합데이터에는 1400만동의 건물이 있었는데, 일부 건물들이 한 자리에 겹쳐있는 오류들을 감안하더라도 왜 그렇게 큰 차이가 나는지는 잘 모르겠다. 


어쨌든 일단 전국 모든 건물에 있는 polygon은 14,184,265 개, 그 polygon을 구성하는 vertex는 94,046,900개.

각 vertex마다 {xy좌표, 건물 높이, 지어진 날짜, 바닥 해발고도, 건물 중심점 xy} 로 구성된 28 Byte 속성을 담았다.


결국 필요한 vram 용량은 94,046,900 x 28 Byte = 2.45GB 로 일단 1080 Ti 메모리인 11GB 안쪽에 충분히 들어왔다.


그래서 모두 넣고 돌려 보았는데 평균 3프레임이 나왔다. 사실 이 때만 해도 모든 데이터를 매 화면마다 반복해서 그리는게 가장 빠른 방식이라고 잘못 알고 있었기 때문에, 초당 3프레임이 최선의 퍼포먼스라고 생각했다.

지형은 서울 경기쪽에만 넣었는데, 이미 건물 만으로도 3프레임밖에 나오지 않았기 때문에 더 넣지 않았다.



위 영상은 직접 조작하면서 화면캡쳐 방식으로 만들었다. 화면이 끊기는 것처럼 보이는데, 원래 그렇다. 1초에 3프레임을 그리기 때문이다.



GIS 프로그램을 다뤄본 사람이라면, 이 프로그램에서 서울과 인천 경기도 정도의 건물 폴리곤만 열어놓아도 zoom과 pan을 하는데 정말 답답할정도로 느려지는 경험을 해봤을 것 같다. 나 역시 그러했기 때문에, 전국의 건물을 1초에 세번 씩이나 그릴 수 있다는 사실에 살짝 놀란 후 기억에서 잊혀져갔다.








Mesh Shader라는 새로운 방식의 등장 


그러다가 어느 날 아래의 영상을 우연히 보게 되었다.



[엔비디아의 Mesh Shader 설명 참고 영상]



2018년 8월에 발매된 엔비디아의 새로운 그래픽카드 라인업의 테스트 영상이다. RTX라고 명명된 이 그래픽 카드들은 새로운 레이트레이싱 전용 코어를 추가함으로써 게임에서 실시간 레이트레이싱이 가능하다는걸 무척 강조했었는데, 위의 영상은 RTX 카드가 지원하는 또 다른 특징을 보여준다.


일단 소행성 개체들이 데이터 상에 굉장히 많이 있는데, 우선 화면 안에 들어오는 소행성들만 걸러서 선택적으로 렌더링 파이프라인에 포함시킨다. 영상 오른쪽 하단의 Total asteroids 숫자가 바로 화면 안에 들어온 소행성의 수를 가리킨다. 각각의 소행성은 LOD에 따라 하나당 최대 5백만개 정도부터 최소 20개까지의 삼각형으로 만들어진다. 화면 안에 들어오는 Total asteroids 숫자 만큼의 소행성을 모두 다 최대 LOD로 그렸을 때 그리게 되는 삼각형의 숫자가 바로 제일 하단의 Max LOD triangles에 나오는 숫자다. 2조 이상의 엄청난 숫자인데, 사실 이걸 다 그릴 수는 없다. 아무도 이걸 다 그리려고 시도하지 않는다. 

그래서 시점으로부터 먼 곳에 있는 소행성들은 단순하게 그리고 가까이 있는 소행성들은 많은 수의 정점들로 자세히 그림으로써 그리기 성능을 최대한 끌어올렸다. 결과적으로 화면 안에 그린 삼각형의 수가 바로 Drawn triangles다.


그런데 재미있게도, 방금 전에 설명했던 전국 건물을 그리는 경우와 정말 딱 맞아떨어졌다. 전국 건물들 1400만동을 하나의 화면에서 보려면 어차피 거의 픽셀 하나보다 작아지기 때문에 건물을 다 그릴 필요가 없다. 심지어는 건물들이 이루는 블록의 형태조차 픽셀 하나로 수렴되는 경우도 대부분이다. 그리고 특정 도시의 건물들을 보려고 화면을 zoom-in해 들어가면 다른 도시의 건물들은 화면에 잘려서 보이지 않게 된다. 그릴 필요가 없다는 말이다.


엔비디아의 설명에 따르면 위의 소행성 작업은 Mesh Shader라고 불리는 새로운 파이프라인 방식으로 가능했는데, 기존의 그래픽 파이프라인 중 마지막 단계인 프래그먼트 셰이더만 남겨놓고 나머지를 모두 대체하고 있었다. 이 shader는 OpenGL의 Nvidia 확장 사양에 해당하기 때문에 AMD가 아닌 Nvidia 그래픽 카드에서만 작동했고, 그것조차 새로운 RTX 카드에서만 지원하고 있었다. 기존 카드에서 불가능한 것인지, 아니면 정책적인 가로막음인지는 잘 모르겠다.



나중에 알게 된 사실이지만,  GTX 카드에서도 화면에서 자르는 컬링이나 동적 LOD 구성을 전혀 할 수 없는 건 아니다. 다만 RTX카드의 경우는 그 작업을 좀 더 효율적으로 할 수 있도록 작업의 틀을 바꾸어준 것이라고 보면 된다. 

기존의 방식으로 한다면, 쉐이더 파이프라인에 보내기 전에 cpu에서 계산하거나 compute shader라는, OpenGL에서 제공하는 약식 병렬계산 모듈을 사용하여 선행처리를 해야 한다. 내가 알지 못하는 다른 방식들도 많이 있을 것이다.


당연히, 선행처리를 한 후에는 그 결과를 파이프라인에 순차적으로 보내기 위해서 어떤 정점들이 걸러졌는지 기록해야 한다. 즉 매개가 되는 내용을 한번 더 생성해서 메모리에 담아 두어야 하는데, 메쉬 쉐이더는 그 과정이 별도의 메모리 저장 없이 두 단계의 쉐이더(task shader + mesh shader)안에서 자연스럽게 연결되도록 만들어 놓았으므로 작업이 좀 더 효율적이 된다.




[메쉬 쉐이더와 기존 쉐이더를 비교, 출처 :https://devblogs.nvidia.com/introduction-turing-mesh-shaders/ ]





기존의 방식과 메쉬 쉐이더의 방식은 엔비디아 홈페이지에서 설명한 위의 그림이 가장 잘 설명해주고 있다. 기존의 파이프라인은 일단 파이프라인 안에 들어온 작업을 중간에 취소하기 어려웠고, 작업이 병렬코어 중 한 쪽에 몰릴 때 실행이 짧은 쪽도 같이 기다려야 한다는 경직성이 있었다. 이에 반해 메쉬 쉐이더 방식은, 상황에 따라 코어들이 유연하게 협동하여 작업을 수행할 수 있도록 쉐이더 작동 방식을 재구성 했다는 얘기다. 동시에 진행하는 작업에서 한쪽이 끝나면 다른 쪽에서 그 코어를 사용할 수 있다는 말인데, 어느 수준까지 유연하게 구성되는지는 모르겠다. 다만, 코드를 짜 본 입장에서 확실한 건 그리지 않을 정점들을 중간에 그룹 단위로 취소하는건 가능하다. 기존에는 그게 되지 않았다.



그래서 기존 방식이 모든 점들을 강제통과 시킨다면, 메쉬 쉐이더는 쉐이더에서 데이터를 선택적으로 끌어와 처리하는 방식이다. 아래 그림에서 개념적으로 비교해봤다. 무조건 다 그려야 하는 경우에는 기존 방식이 효율적일 것 같은데 확인은 해보지 못했다.




전통적인 그래프 파이프라인에서 버텍스를 그리지 않을 수 있는 방법은 파이프라인의 끝에서 discard 명령을 내림으로써 가능하다.  메쉬 쉐이더는 두 단계로 이루어지는데 task shader단계에서 인접한 삼각형 그룹의 대표 데이터만 불러들여 화면에 그릴 것인지 여부를 결정하고, 결정된 그룹들만 불러와서 그릴 수 있다. 

위의 다이어그램에서 설명한 내용 이외에도 중간에 동적으로 메쉬들을 생성해낼 수 있는데, 앞에서 설명한 것처럼 기존 지오메트리 쉐이더보다 코어들이 좀 더 협동적으로 작업할 수 있도록 설계해 놓았다고 한다.





전국의 모든 건물을 Mesh Shader로 10배 빠르게 그리기


그래서, 일단 시도해보기로 했다.


일종의 문법을 헤매면서 익히는 과정이라 시간이 꽤 소요되었다. 사실 대부분의 모르는 내용들은 검색해보면 이미 세상의 천재들이 설명도 자세히 해주고 구현된 코드도 올려놓았으며 서로 잘못된 점들도 막 지적하고 있는데, 메쉬 쉐이더만큼은 엔비디아의 소개 글 하나와 깃헙에서 딱 두개의 샘플을 찾을 수 있었다. 엔비디아에서 올려 놓은 코드는 다른 내용들과 너무 복잡하게 섞여 있어서 어디부터 어디까지가 메쉬 쉐이더에서 필요한 부분인지를 가려내기 힘들었는데, 그 코드를 옮겨와서 수정한 듯한 다른 코드 하나와 비교해보면서 더듬어가야 했다.


결과적으로는 Mesh Shader를 이용해서 1400만개의 건물과 가로세로 25m 간격의 전국 지형, 그리고 하천과 도로들을 초당 최소 30프레임에서 최대 60프레임 이상으로 그릴 수 있었다. 10배 정도 성능이 향상된 셈이다.


사용된 데이터는 다음과 같다.


건물 삼각형 50,974,462개 / 저장공간 1766MB 

하천 삼각형 20,421,241개 / 저장공간 386MB

도로 삼각형 88,474,593개 / 저장공간 1669MB

지형 정점  636,645,681개 / 저장공간 1214MB 



모든 데이터들은 물리적으로 인접한 것들끼리 그룹으로 묶었다. 엔비디아에서 메쉬 쉐이더를 설명할때 이 그룹 메쉬들을 meshlet이라고 부른다. 이 작업이 살짝 번거로운데, 이 전처리를 잘 해 놓아야 데이터를 걸러낼때 효율적이 된다.


일단 절두체 컬링(Frustum culling)이라 불리는 방법으로 화면에 보이지 않는 개체들을 렌더링 파이프라인에서 제외시켰다. 모든 점들을 스캔하지 않고, 바로 앞에서 언급한 그룹의 대표 정점 하나만 체크해주면 된다. 만약 64개 정점들을 하나로 묶어주었다면, 스캐닝하는 속도는 모두 다 볼 때보다 1/64이 된다. 이것저것 생략하고 말하자면, 1초에 1프레임 나오던 것이 64프레임이 나올 수 있다는 얘기다. (물론 이 스캐닝 속도가 전체 소요시간에서 그렇게 결정적이지는 않다) 

그리고 전국을 한 화면에 볼 경우 매우 작아지기 때문에 전체 건물들의 10%만 그리도록 조정했다. 지형의 경우 25m 간격마다 라인 하나 긋던 것을, 멀리 있을 경우 175m간격마다 라인 하나로 단순화시켰다.


지형의 경우 격자 단위 25미터로 백령도, 독도, 마라도를 포함하는 전체 직사각형 영역에 대해서 2바이트씩 높이 정보를 넣었는데, 절반 이상이 0으로 된 데이터(바다 한가운데)를 포함하지만 1차 컬링 작업에서 빠른 시간안에 해당 그룹이 걸러져서 그대로 사용했다.


화면에 보이지 않는 반대쪽 폴리곤, 그러니까 시점에서는 전혀 보이지 않는 건물의 뒷면을 생략할 경우 좀 더 성능이 향상될 수 있다. 이 방식을 backface culling이라고 부르는데, 그냥 하지 않고 두었다. 지붕을 벽면 위의 끝선까지 올려서 그린다면 backface culling을 해도 되겠지만, 아래 그림처럼 파라핏 부분을 조금 남기고 싶었다. 그래야 약간 좀 더 건물처럼 보이니까. 참고로 아래에서 건물이 이상하게 겹쳐있는 부분은 원래 데이터가 중복되어 있어 그렇다. 오른쪽 하단 부분에 옆벽면이 살짝 검게 깨진 것처럼 보이는 부분 역시 건물들이 겹쳐 있어서 그렇다.





절두체 컬링이나 LOD 같은 경우 아마도 게임 개발자들은 기본적으로 염두에 두는 전처리 방식일 것이라 생각한다. 넓을 벌판을 뛰어다니면서 사냥을 할 경우에, 모든 프레임마다 넓은 벌판을 다 그리거나 멀리있는 나무까지 자세히 그리지는 않을 것이기 때문이다. 실제로 관련 기술 문서들을 검색해보면 대부분이 게임 관련 예시를 들고 있으며, 일부분은 CAD 를 다룬다.


사실 이렇게 병렬 작업을 구상하여 쉐이더를 만드는게 단순하지는 않다.

기존의 쉐이더는 딱 한가지 데이터만 처리한다는 생각으로 코드를 작성한 후 몽땅 파이프라인으로 보내면 나머지는 GPU가 알아서 병렬적으로 처리해 주었다. 그런데 메쉬 셰이더의 경우 데이터를 끌어와야 하므로 메모리에서 읽어올 정점에 대한 일련 번호 관리부터 어떻게 작업을 분해할 것인지 단계에 따라 얼마만큼 규모로 하위 그룹을 만들어 나갈 것인지를 직접 결정해서 코드를 작성해야 한다.


그래도 까다로운만큼 얻게되는 장점도 분명히 있다. 전국의 모든 건물을 그리는 경우라면 열 배가 넘는 속도가 나오기 때문에 실시간으로 답답하지 않게 볼 수 있다. 생각해보니 전국의 산과 강과 건물, 그리고 도로까지 빠른 속도로 훑어본 기억은 아직 없었던 것 같다. 지형의 높이를 강조하면 부산과 같은 도시들이 들어서 앉아 있는 지리적 조건이 좀 더 잘 보인다.


시간에 따라 건물들이 들어서는 장면들은 덤으로 얻을 수 있다. 그런데, 사용승인연도의 데이터가 정확하지 않아서 약간 걸러서 봐야 한다. 사용승인 연도 자체가 없는 건물들은 어쩔 수 없이 이미 들어서 있는 것처럼 표현이 되었다. 서울 같은 경우 동대문구의 건물들은 최근에 하나도 지어지지 않은 것처럼 보이지만, 데이터에서 누락되었기 때문에 그러한 것이다.


아래 영상 역시 실시간으로 마우스를 조작하고 화면 캡쳐 방식(칼무리 사용)으로 만들었다.

컴퓨팅 환경은 i7-7700K + RTX 2080 Ti. 사실 cpu는 별로 중요하지 않다. 절반도 사용하지 않는다.


도시계획에 관심있는 사람이라면 1분 32초부터 건물이 들어서는 장면을 볼 수 있다. (이때는 + 버튼을 꾹 누르고 있었다. 그러면 시간이 진행되도록 코드를 짜 놓았다) 1993년 정도부터 일산, 분당, 평촌 정도를 눈여겨보면 신도시가 생겨나는 장면을 목격할 수 있다. 지하철 노선은 깍뚜기로 넣었다.



끝.









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

참고로, 미국에서도 작년 말에 모든 건물을 그리는 시도를 했다. (미국의 이 건물 데이터는 총 1억 2천만동이나 된다.)

https://www.nytimes.com/interactive/2018/10/12/us/map-of-every-building-in-the-united-states.html

MS가 공개한 데이터베이스를 바탕으로 다단계 LOD 타일 맵을 만든 것 같은데, 정확한 기술적인 내용은 잘 모르겠다.

물론, 위 링크에서 한 일은 많은 사람들이 접근할 수 있는 온라인경험이다. "내가 봤다" 가 아니라 "너도 볼 수 있다"라는 점에서 큰 의미가 있다.