본문 바로가기

Function

Vulkan 에서 Mesh Shader + Ray Tracing

작업 환경을 OpenGL에서 Vulkan으로 바꿨다. 물론 Vulkan 같은 경우 삼각형 하나 그리는데 1000줄 정도의 코드가 필요하고 동기화 문제 등 여간 작업이 까다로운게 아니라 아직 경우에 따라 OpenGL 기반 작업을 병행하고 있다.

 

Vulkan은 DirectX12 처럼 차세대 라이브러리라서, Nvidia 와 AMD의 최신 하드웨어가 제공하는 기술들을 모두 지원해준다. 반면 OpenGL을 사용할 경우 Ray Tracing 관련 API가 좀 제한적이다. Optix와 병행해야 하는데, 이종의 라이브러리를 결합하는 격이라 어딘가 매끄럽지 못하다. Vulkan으로 넘어온 김에 Mesh Shader와 Ray Tracing 기능을 결합해봤다.

 

 

Mesh Shader

 

Mesh Shader 관련해서는 지난번에 써놓은 글이 있다.

 

Nvidia Mesh Shader 코드를 작성해보자

얼마 전 Unreal Engine 5의 데모가 공개되면서 엄청난 디테일의 표현을 가능하게 한 Nvidia 의 Mesh Shader 방식이 화제가 되었다. A first look at Unreal Engine 5 Get a glimpse of new and improved real-time rendering features c

www.vw-lab.com

 

 

Ray Tracing

 

Ray Tracing에 대해 간단히 설명하자면, 빛 벡터와 물체를 구성하는 삼각형의 충돌을 감지하는 알고리즘이 가장 밑에 깔리고, 그 계산을 바탕으로 하는 물체 표면의 특정, 그림자, 간접광 확산 등 다양한 기법들을 총칭하는 개념이다.

래스터라이징 방식과 종종 비교되는데, 하나의 삼각형을 그린다고 할 때, 래스터라이징 방식은 일단 삼각형의 정점들을 발생시켜 삼각형을 구성하고, 그 삼각형 내부의 픽셀들 하나하나의 색상을 결정하는 과정으로 삼각형을 칠하게 된다.

반면, 레이트레이싱 방식은 삼각형 데이터가 별도로 있고, 픽셀과 화면의 시점을 이어주는 벡터를 구성한 뒤, 그 벡터가 삼각형과 충돌하면 삼각형이라 특정해서 그에 맞는 색상을 칠해주는 방식이다. 두 방식은 파이프라인 구성상 근본 원리가 다르지만, 하나의 프레임을 만들때 섞어서 사용할 수도 있다.

 

레이트레이싱 계산 속도에서 중요한 부분은 빛과 삼각형의 충돌을 감지하는 기술이다. 모든 삼각형을 루프를 돌면서 충돌을 감지하는것은 비효율적이기 때문에, 삼각형의 자료구조를 어떻게 만들어서 충돌하는 삼각형에 근접하게 검색할 것인가가 소프트웨어적인 해결 방식에서의 도전과제였다. 

몇 년 전에 나온 Nvidia 에서 발표한 RTX 카드는 이 충돌을 하드웨어적으로 지원한다. 사실 정확히 내부에서 어떤 연산이 일어나는지는 잘 모르겠다.  Acceleration Structure 라고 불리는 함수 내부의 버퍼에 삼각형들을 등록해주면 그와 관련된 효율적 자료구조가 만들어지고, 관련 연산 모듈을 효율적으로 수행하는 하드웨어를 넣었기 때문에 일반 셰이더 계산보다 훨씬 빨라졌다는 정도만 알고 있다.

가속 구조에 등록된 삼각형들을 Nvidia Nsight 에서 조회해 볼 수 있다.

 

물론 Ray Tracing 기능이 하드웨어적으로 지원해주는것은 삼각형과 지정한 벡터의 충돌 여부까지만이다. 당연히 항상 그렇듯 모든 렌더링 테크닉은 직접 작성해야만 한다. 여기서는 가장 많이 넣는 두 가지 정도 효과를 넣었다.

 

 

 

첫번째는 RTAO. Ambient Occlusion 을 ray trace 방식으로 계산해서 넣었다. 직사광을 받지 않는 부분에서 음각으로 들어간 부분들일수록 어둡게 보이는 효과를 말한다. 특정 표면의 한 픽셀에서 주변으로 랜덤하게 10~100개 정도의 광선을 발생시킨 후, 가까이에서 다른 표면이 감지되면 해당 픽셀을 좀 더 어둡게 만든다.

 

 

 

 

 

 

그림자. 특정 표면에서 광원을 연결했을 때 가리는 물체가 있으면 그림자를 떨군다. 위처럼 부드러운 그림자를 넣는 기법은 여러가지가 있다고 하는데, 여기서는 광원이 넓다고 가정한 후, 한 지점에서 광원 주변으로 랜덤하게 다른 광원들을 가정하고 다시 빛을 쏘아서 그림자를 추가했다.

 

 

 

 

 

 

 

이제 두 가지 효과를 적정하게 섞으면 위처럼 자연스러운 효과가 나온다.

 

 

 

 

 

여기에 색상이나 머티리얼, 선행 렌더 패스에서의 다른 내용들을 추가하면 의도한 화면이 완성된다.

 

 

 

 

 

Mesh Shader 와 Ray Tracing을 조합해보자. 우선 데이터부터...

 

 

이제 Mesh Shader와 Ray Tracing을 순차적으로 조합해보자. 그런데 문제는 Mesh Shader와 하드웨어 지원 Ray Tracing이라는 같은 회사의 기술이, 게다가 결합되면 꽤 좋은 시너지를 만들어낼 수 있는 기술의 결합이 절차적으로 매끄럽지 못하다는 사실이다. 이번 작업의 사례를 통해 설명해보겠다.

 

위에서 링크된 메시 셰이더 작업에서는 건물 1766MB, 하천 386MB, 도로 1669MB, 지형 1214MB의 데이터를 사용했다. 아래에서 설명하는 작업에서는 하천과 도로를 지형과 같은 그리드 크기로 잘게 분할하느라 용량이 각각 두 배 가량 늘어났다. 총 7GB 정도의 데이터가 되었다.

 

하천과 도로를 다시 분할한 이유는 아래 그림처럼 지형에 묻히는 부분들을 지형과 align 시켜, 표면 위에 잘 드러나게 해주기 위함이다.

 

도로가 일부 지형에 묻힘
도로가 지형 표면 위에 잘 드러나 있음

 

관련 작업은 아래의 글에 설명했다.

 

 

폴리곤을 삼각형 격자로 분할하기

위와 같은 연두색 폴리곤 하나를 아래와 같은 폴리곤들로 분할해 주는 방법에 대해 개략적으로 설명해보겠다. 폴리곤의 분할 기준은 크기가 입력된 정사각형 격자를 대각선으로 가르는 직각삼

www.vw-lab.com

 

 

메시 셰이더는 별도의 데이터를 저장하지 않고 끝난다. 그래서 빠르다.

 

이렇게 준비된 데이터를 메시 셰이더로 그리는 과정을 간략화 하면 아래 그림과 같다.

 

대용량 데이터를 그릴 때, 메시 셰이더 방식을 컴퓨트 셰이더 전처리와 비교해보자면 가장 큰 장점은, 별도의 중간 버퍼 저장 과정이 없다는 점이다. 컴퓨트 셰이더로 1차 컬링을 하고, 필터링된 데이터를 2차적으로 그리려면 두 과정을 연계하기 위한 버퍼가 필요하다. 모두 새로 쓰지 않더라도 데이터 청크를 지칭하는 간접 버퍼 인덱스들을 Atomic 연산을 통해 기록해줘야 한다.

 

대신 메시 셰이더는 그 과정이 하나의 파이프라인에서 이루어지므로 별도의 버퍼를 저장하지 않고 파이프라인 간의 연계 과정에서 아주 빠른 메모리(캐시)를 사용하게 된다. 

 

 

 

레이 트레이싱에는 가속 구조 생성 절차가 선행되어야 한다

 

그런데 레이트레이싱과 연계할 때는 문제가 생긴다. 별도의 버퍼를 발생시키지 않고 곧바로 래스터라이징하기 때문에, task shader에서 frustum culling을 하고 mesh shader 단계에서 래스터라이징하고 빠져나온 후에는 외부에서 필터링된 데이터가 무엇인지 알 방법이 없다.

 

따라서, 레이트레이싱을 위해서는 위와 같이 화면 안에 들어오는 부분만 잘라내고 나면(frustum culling), 그 잘라낸 삼각형들로 가속구조(acceleration structure)를 생성해줘야 하는데, 저장되는 버퍼가 없으므로 가속구조를 만들수도 없다. 당연히 레이트레이싱 작업도 불가능하다.  아예 존재하는 모든 삼각형들을 미리 가속구조로 만들어주면 되는데, 데이터의 양이 많을때는 메모리와 한계와 퍼포먼스 저하로 그렇게 할 수가 없다. 

 

다시 말해, 아래와 같이 별도 버퍼를 생성한 후, 그 버퍼로 가속 구조도 만들어줘야 레이 트레이싱이 가능하게 된다. 그런데 메시 셰이더를 가장 효율적으로 이용하려면 곧바로 래스터라이징으로 끝내버려야 하기 때문에, 정상적인 과정이라면 아래 버퍼는 존재할 틈이 없다. 

 

 

 

두 작업을 연계하면 병목이 발생한다.

 

 

 

결국 메시 셰이더 작업 이후에 레이 트레이싱을 해주려면, 메시셰이더에서 삼각형들을 프래그먼트 셰이더로 보내기 직전에 별도 버퍼에 저장해주어야만 한다. 이 과정에 병목이 걸린다. 버퍼를 차곡차곡 쌓으려면 어쩔 수 없이 atomic 연산으로 번호를 붙여주는 작업을 해야 하기 때문이다.

 

 

 

위의 그림에서 '추가비용'이라고 지칭한 부분이 두 작업을 연계하기 위해 부가적으로 발생하는 작업들이다. 

물론 이런 의문이 들 수도 있다. '동적인 화면에서는 항상 화면 삼각형들의 위치가 바뀌는데, 그렇다면 Ray Tracing은 항상 불리한 조건이 아닌가?'

사실 가속구조는 두 개의 계층으로 나뉜다. Bottom Level Acceleration Structure(BLAS)와 Top Level Acceleration Structure(TLAS)의 두 가지다. BLAS에는 물체의 기본 형상을 이루는 삼각형이 들어가고, TLAS에는 그 삼각형들을 이동 및 회전시킬 수 있는 행렬이 들어간다. 통상적인 삼각형을 그릴 때, Model, View, Projection 세 개의 매트릭스를 거친다고 할 때, TLAS는 Model Matrix 역할을 한다고 보면 이해하기 쉽다.

 

따라서 화면의 물체가 한정적이고 동적으로 움직일 뿐인 화면에서는 TLAS만 갱신해주면 되기 때문에, 훨씬 비용이 적게 든다. 그러나 여기서 구현하는 상황에서는 원래 데이터는 워낙 많고, 화면 한 프레임에 들어오는 삼각형들만 잘라내도 몇천만개가 될 때도 있기 때문에, BLAS에 미리 등록을 해주기는 어려운 상황이다.

 

그래도 조금 느리지만 Ray Tracing의 매력은, 아래와 같이 밋밋한 화면에 그럴듯한, 게다가 정직한(?) 빛 효과를 더해주기 때문이다.

 

ray tracing 효과 적용 안하는 경우

 

 

ray tracing 효과 적용 후

아. 물론 그림자를 넣어주고 Ambient Occlusion을 적용하는 다른 방식도 많다. Ray Tracing을 정직하게 적용하는 비용이 너무 크기 때문에, 비슷한 효과를 내기 위해 여러가지 트릭들을 고안하는게 게임그래픽 같은 분야에서의 도전과제들이기도 했다. 

 

 

실제 커맨드 버퍼와 렌더 패스

 

위에서 도식화한 절차를, 실제 Command Buffer나 Render Pass 등으로 나누어 보면 아래와 같다. 위에서 부터 아래로 진행되며, 한 프레임을 그리기 위해 해야 하는 절차라고 생각하면 된다.

 

 

사실, 아주 잘 만든 렌더 패스라고 말할 수는 없다. Vulkan은 동기화가 늘 골치아픈데, 이러저러한 방식으로 시도해보다가 validation error 가 계속 발생해서 되돌리기도 했다. 혹은 선행 작업이 완료되기 전에 다음 작업이 완료되는 바람에 개체들의 진행이 꼬이는 등 여러가지 문제가 발생하기도 했다.

그래서 결국 두 부분에 fence 동기화를 넣어서 cpu-gpu 사이에 한 번 작업을 끊어줄 수 밖에 없었다.

 

가속 구조를 만드는 작업은 렌더 패스 외부에서 cpu 명령을 내려줄 수 밖에 없는데, 이 부분에서 fence 동기화를 넣지 않으려면, 완벽한 더블 버퍼링을 구성해서 적용한 효과 중 일부를 한 두 프레임 뒤 쪽으로 미루는 등의 방식으로 좀 더 매끄럽게 보여줄 수 있는 방법도 있다. 물론 그냥 그렇게 하면 되는 것이 아니라, 급격한 화면 전환이 없도록 조작 부분도 바꿔줘야 하는 등 다른 작업과도 연관되어 있다. 여튼, 아직 비용 개선의 여지가 많이 있을 것 같다.

 

graphic queue 대신, 레이트레이싱이 이루어지는 compute shader만 별도 분리해서 compute queue에도 넣어봤는데, 어차피 cuda core가 꽉 차 있어서 그런지, 그닥 효율도 오르지 않고 동기화 문제만 많이 터지는 바람에 다시 graphic queue로 모두 정리했다. 찾아보니 AMD 아닌 Nvidia 는 두 queue 가 효율적으로 병렬진행이 안된다는 말도 있고... 잘 모르겠다.

 

 

 

Nsight로 보기

 

같은 장면에 레이트레이싱을 넣고 빼고는 차이가 크다.

메시 셰이더만 거치면 위와 같은 장면은 10ms 안에서 한 프레임이 그려진다.

 

 

여기에 ray tracing 을 추가하면 157ms 로 증가한다. 초당 6프레임 정도다.

메시 셰이더만 거쳤을 때 6.55ms 였던 건물 렌더링은 버퍼에 저장하는 과정이 추가되면서 87.29ms로 늘어났다. 그 다음에 25.28ms가 가속 구조 등록, 39.47ms가 레이 트레이싱 부분이다.

 

 

GPU Trace로 비슷한 장면을 다시 보면, 194.39ms라는 엄청난 시간의 작업이 등장한다. 이게 바로 건물을 그리는 부분인데, 좀 더 아래를 보면 SM 은 거의 쓰지 않고, L2 Hit Rate만 높다. 아마도 한 줄로 서서 atomic 저장을 하느라고 메모리만 많이 사용하고 계산은 별로 하지 않기 때문에 저렇게 나오는 것 같다.(해석이 정확한지 잘 모르겠다. Nsight 보는 법을 더 익혀야 한다)

 

그래서, 전체적으로 화면 복잡도에 따라 60~350fps 이 나왔는데, 레이 트레이싱 작업이 추가된 후 5fps~45fps 로 떨어졌다. 납품용은 아니고 중간에 가지쳐서 만든 실험용 작업이라 실질적인 문제가 되지는 않지만, 시간날 때 다시 성능 높일 수 있는 부분을 찾아봐야겠다.

 

 

마무리. 그림 몇 장

 

캡쳐한 그림들 몇 장 넣어놓고 마무리해본다.