사전 지식
1. 콤퓨트 셰이더
2. 기존 렌더링 파이프라인
여기에 작성된 글은 번역이 아닌 번역자 코멘터리
원문 : Introduction to Turing Mesh Shaders | NVIDIA Developer Blog
튜링 아키텍쳐는 메시 셰이더 사용을 통해 새로운 지오메트리 셰이딩 파이프라인을 가져옵니다. 이 새로운 셰이더는 그래픽스 파이프라인에 콤퓨트 프로그래밍 모델을 가져오는데, 이 스레드는 래스터라이저에서 사용하려고 컴팩트 메쉬(메쉬렛)를 칩에다 바로 생성하는데 협력적으로 사용됩니다. 높은 복잡도의 지오메트리를 다루는 앱이나 게임은 효과적인 컬링이나 LOD기술 처럼 유연한 2단계의 접근 방식으로부터 이점을 얻습니다.
이 블로그 글을 새로운 파이프라인을 소개하고, OpenGL이나 Vulkan 렌더링을 위한 구체적인 GLSL 예제를 제공합니다. 이 새로운 기능들은 OpenGL과 Vulkan의 확장을 통해서나 다이렉트X 12 Ultimate를 사용해 접근할 수 있습니다.
대부분의 내용은 프리젠테이션에서 가져온 것이고, 전체 슬라이드는 나중에 공개됩니다.
1. 메시 셰이딩 파이프라인
2. 메쉬렛과 메시 셰이딩
3. 미리 계산된 메쉬렛
3.1 데이터 구조
3.2 렝더링 리소스와 데이터 흐름
3.3 태스크 셰이더를 이용한 클러스터 컬링
4. 결론
5. 레퍼런스
모티베이션
현실 세계는 시각적으로 풍부하고 기하학적으로 복잡한 장소입니다. 특히 야외 씬들은 수십만개의 요소(바위등, 나무들, 작은 식물들, 기타 등등)로 구성될 수 있습니다. CAD 모델은 수 많은 작은 파츠로 만들어진 기계나 복잡한 모양의 표면 모두에 비슷한 문제를 제기합니다.
(생략)
이 포스트에서 우리는 메시 셰이더로 무거운 삼각형 메시들의 렌더링을 가속화 해 볼 것입니다. 원본 메시는 아래 figure 2 그림처럼 작은 메쉬렛으로 분할됩니다. 각 메시렛들은 그 안에서 버텍스를 재사용하도록 이상적으로 최적화합니다. 새로운 하드웨어 단계와 이 분할 방식을 사용하면 우리는 적은 데이터를 fetching 하면서 많은 지오메트리를 렌더링 할 수 있습니다.
예를 들어, CAD 데이터는 수 천만개에서 수억개의 트라이앵글에 도달하기도 합니다. 심지어 오클루전 컬링 후에도 유의미한 수의 트라이앵글이 존재할 수도 있습니다. 파이프라인에서 몇몇 고정된 단계들은 다음과 같은 상황에서 꽤 낭비적인 작업과 메모리 로딩을 수행합니다.
- 토폴로지가 유지될 때에도 매번 인덱스 버퍼를 스캐닝 하는 하드웨어의 프리미티브 디스트리뷰터에 의한 버텍스 배치(batch) 생성
- 보이지 않는 버텍스나 속성 데이터를 가져오는 일(백페이스, 프러스텀, 서브픽셀 컬링 등)
이 메시 셰이더는 이와 같은 병목현상을 피할 수 있는 가능성을 제공합니다. 이 새로운 접근법은 이전 방식과 다르게 메모리를 한 번 읽은 다음 칩에서 유지할 수 있게 해줍니다. 프리미티브 컬링에 기반한 콤퓨트 셰이더(Figure 3, 4, 5를 보세요)가 보이는 삼각형의 인덱스 버퍼들을 Indirect로 계산해서 그리는 것처럼요.
메시 셰이더 단계는 래스터라이저를 위한 삼각형을 생성합니다만, 싱글 스레드 프로그램 모델을 사용하는 대신에 콤퓨트 셰이더 같은 병렬 스레드를 사용합니다. 파이프라인에서 이 메시 셰이더보다 앞서는 것이 태스크 셰이더입니다. 이 태스크 셰이더는 테셀레이션의 컨트롤 단계와 비슷하게 작동하는데, 동적으로 작업을 생성할 수 있다는 점에서 그렇습니다. 그러나, 메시 셰이더와 마찬가지로 병렬 스레드 모델을 사용합니다. 또, patch를 입력으로 받는 테셀레이션과 다르게 인풋과 아웃풋은 사용자가 정의해 사용합니다.
이 간소화된 온칩 지오메트리 생성 방식은 Figure 3에서 볼 수 있듯이 스레드들이 특정한 작업에만 사용되어야만 했던 이전의 경직되고 제한된 테셀레이션과 지오메트리 셰이더와 비교됩니다.
메시 셰이더 파이프라인
새로운 2단계의 파이프라인은 기존의 속성 fetch, 버텍스, 테셀레이션, 지오메트리 셰이더를 대체합니다. 이 새로운 파이프라인은 태스크 셰이더와 메시 셰이더로 구성되어 있습니다.
- 태스트 셰이더 : 작업 그룹에서 작동하고 각각의 메시 셰이더 작업 그룹들을 emit 할 수 있는 프로그래머블 유닛
- 메시 셰이더 : 작업 그룹에서 작동하고 각각의 프리미티브를 생성하는 프로그래머블 유닛
이 메시 셰이더 단계는 래스터라이저를 위한 삼각형들을 생성하는데, 내부적으로는 위에서 언급했던 병렬 스레드 모델을 사용합니다. 이 태스크 셰이더는 다이나믹하게 작업을 생성한다는 점에서 테셀레이션의 헐 셰이더 단계와 비슷하게 동작합니다. 그러나 메시 셰이더처럼 태스크 셰이더 역시 병렬 스레드 모드를 사용합니다. 인풋들과 아웃풋은 사용자가 정의합니다. (테셀레이션은 patch로 지정되어있음)
픽셀/프래그먼트 셰이더와의 인터페이스는 영향을 받지 않습니다. 고전적인 파이프라인은 여전히 사용가능하며 사용 상황에 따라 좋은 결과들을 제공할 수 있습니다. Figure 4를 보면 두 파이프라인 스타일의 차이를 확인할 수 있습니다.
이 새로운 메시 셰이더 파이프라인은 개발자들에게 다음과 같은 이점을 제공합니다.
- 높은 확장성
프리미티브 프로세싱 과정에서 고정된 함수를 줄임으로써 셰이더 유닛을 통한 높은 확장성을 가질 수 있습니다. - 대역폭 감소
중복되는 버텍스를 제거하는 작업(버텍스 재사용)이 초기에 진행될 수 있으며, 버텍스가 여러 프레임에 걸쳐 재사용 될 수 있습니다. 현재의 API 모델은 인덱스 버퍼들이 하드웨어에 의해 매번 스캔되어야만 합니다. 메쉬렛이 커지면 버텍스 재사용도 커지고, 대역폭 요구사항은 줄어들게 됩니다. 게다가, 개발자들은 그들만의 압축이나 절차적 생성 방식을 사용할 수 있게 됩니다. 태스크 셰이더를 통한 선택적인 확장과 필터링을 통해서 추가 데이터를 가져오는 작업을 완전히 건너뛸 수 있습니다. - 유연성
그래픽 작업을 생성하고 메쉬 토폴로지를 정의하는 유연성을 가집니다. 이전의 테셀레이션 셰이더들은 지오메트리 셰이더가 비효율적인 스레딩과 스레드마다 트라이앵글을 생성하는 프로그래밍 친화적이지 못한 고정된 테셀레이션 패턴에 제한되었습니다.
메시 셰이딩은 콤퓨트 셰이더의 프로그래밍 모델을 따르며, 개발자들에게 다양한 목적으로 스레드들을 사용하고 스레드간 데이터를 공유할 수 있는 자유를 부여합니다. 래스터라이제이션이 비활성화되면, 이 두 단계를 통해 하나의 확장 레벨로 통상적인 계산 작업을 할 수 있습니다.
메시 셰이더와 태스크 셰이더는 콤퓨트 셰이더 프로그래밍을 따르며, 결과를 계산 할 때는 작업 그룹 인덱스 외에는 입력을 받지 않는 병렬적인 스레드 그룹을 사용합니다. 이는 그래픽스 파이프라인 위에서 실행되고, 그러므로 하드웨어는 칩에서 직접 각 단계 사이에 메모리를 관리하고 넘겨줍니다.
스레드가 작업 그룹 내 모든 버텍스들에 접근할 수 있어서 이로서 어떻게 프리미티브 컬링을 수행할 수 있는지 예시를 보여드리겠습니다. Figure 6은 태스크 셰이더가 이른 컬링을 수행하는 기능을 보여줍니다.
태스크 셰이더를 통한 선택적인 확장은 프리미티브 그룹의 이른 컬링이나 LOD 결정을 초기에 할 수 있게 해 줍니다. 이 메커니즘은 GPU에서 스케일되고 따라서 Superseding 인스턴싱이나 작은 메시들은 Indirect로 그리게 됩니다. 위 구성은 얼마나 patch가 테셀레이트 될지(태스크 작업그룹)나 얼마나 많은 테셀레이션 이밸류애이션이 생성될 지(메시 작업그룹) 영향을 미치는 것처럼 테셀레이션 컨트롤 셰이더와 유사합니다.
하나의 단일 태스크 작업 그룹이 emit 할 수 있는 메시 작업그룹의 수에는 제약이 있습니다. 첫 번째 세대 하드웨어는 태스크 당 64K개 까지의 차일드가 생성될 수 있습니다. 그러나 한 번의 드로우 콜에서 모든 태스크에 걸쳐서 메시 차일드들의 개수에는 제약이 없습니다. 마찬가지로 태스크 셰이더가 사용되지 않으면, 한 번의 드로우콜로 생성되는 메시 작업그룹의 수에도 제한이 없게 됩니다. Figure 7을 보시면 어떻게 동작하는지 확인할 수 있습니다.
태스크 당 제한은 있으나 드로우 콜 당 핸들링 할 수 있는 메시 차일드 개수에 제약이 없어서 많은 차일드를 프로세싱하려면 태스크 수를 늘리면 됩니다. 태스크를 아예 사용하지 않으면 메시 작업 그룹의 사용제한 역시 없어집니다. 태스크 작업그룹이 처리할 수 있는 메시 작업그룹의 수에 한계가 있기 떄문입니다.
태스크 T의 자식들(Children)은 태스크 T-1의 자식들이 Launch 된 다음 Launch 되도록 보장됩니다. 그러나 태스크 작업그룹과 메시 작업그룹이 완전히 파이프라인으로 되어있어서, 이전 자식이나 이전 태스크들의 완료를 기다릴 필요가 없습니다.
태스크 셰이더는 동적 작업 생성이나 필터링에 사용되어야 합니다. 정적인 설정은 메시 셰이더만 사용할 때 이점을 볼 수 있습니다.
메시들과 그 안에 포함된 프리미티브들의 래스터라이제이션 출력 순서는 보존됩니다. 래스터라이제이션이 비활성화되면, 작업 셰이더와 메시 셰이더들은 기본 콤퓨트 트리를 구현하는데 사용될 수 있게 됩니다.
메쉬렛들과 메시 셰이딩
각각의 메시렛은 정점들과 프리미티브들의 변할 수 있는 숫자를 나타냅니다. 이러한 프리미티브들의 연결에 따로 제약이 있지 않습니다. 그러나, 프리미티브들은 반드시 셰이더 코드에서 정의된 최대 양 이하로 유지되어야합니다.
저희는 최대로 64개의 정점과 126개의 프리미티브를 사용할 것을 권장합니다. 126에서 '6'은 오타가 아닙니다. 1세대 하드웨어는 프리티비드 정적들을 128바이트로 세분화하고 프리미티브 개수를 위해서 4바이트를 사용해야합니다. 그래서 3 * 126 + 4가 3 * 128 = 384 바이트 블록에 최대로 맞춰집니다. 만약 126개의 삼각형보다 많아진다면 다음 128바이트에 할당될 것입니다. 이와 같은 값으로는 84와 40이 있고, 다른 큰 수를 결정해서 사용할 수도 있습니다.
4바이트를 계속해서 사용해야하므로 384-4 = 380이고, 126개의 프리미티브를 사용하면 126 * 3 = 378으로 2바이트를 남기고 최대로 사용할 수 있습니다.
각각의 GLSL 메시 셰이더 코드에서, 작업 그룹 당 고정된 메시 메모리가 모든 작업 그룹의 그래픽스 파이프라인에 할당됩니다.
최대값들과 크기들, 프리미티브 출력은 다음과 같이 정의됩니다.
각 메시렛의 할당 크기는 셰이더가 참조하는 출력 속성인 컴파일타임의 크기 정보에 의존합니다. 할당 크기가 작을수록, 하드웨어에서는 더 많은 워크그룹이 한 번에 실행 가능해집니다. 콤퓨트와 마찬가지로, 작업그룹들은 온칩 메모리에서 액세스 가능한 커먼 섹션을 공유합니다. 그러면 저희는 모든 출력이나 공유 메모리가 사용되는 곳에서 가능한 효율적이어야 함을 추천합니다. 이건 이미 기존 파이프라인의 셰이더에서도 마찬가지인 사실입니다. 그러나 기존 방식보다 새로운 방식에서는 훨씬 많은 정점과 프리미티브를 다루기 때문에 현재 프로그래밍에서는 메모리 사용량이 더 커질 수 있습니다.
// 작업 그룹 당 스레드 수 설정 (항상 일차원)
// 실제 콤퓨트 셰이더에서와는 제약사항이 다를 수 있습니다.
layout(local_size_x=32) in;
// 프리미티브 타입 (포인트, 라인 또는 트라이앵글)
layout(triangles) out;
// 각 메시렛의 최대 할당 크기
layout(max_vertices=64, max_privitives=126) out;
// 작업 그룹 출력의 실제 양 (max_primitive 보다 작아야 합니다.)
out uint gl_PrimitiveCountNV;
// list 타입 정점의 인덱스 버퍼
out uint gl_PrimitiveIndicesNV[]; // 삼각형이므로 크기는 max_primitives * 3
튜링은 NV_fragment_shader_barycentric이라는 다른 새로운 GLSL 확장을 지원합니다. 이것은 프래그먼트 셰이더가 3개의 정점의 로우데이터를 가져와 수동으로 보간해 프리미티브를 만들 수 있게 해줍니다. 이 저수준의 액세스는 우리가 'uint' 버텍스 속성을 출력할 수 있지만 다양한 패킹, 언패킹 함수를 사용해 float들을 fp16, unorm8 또는 snorm8로 저장할 수 있음을 의미합니다. 다시말해서 이것은 노멀과 텍스쳐 좌표(UV), 기본 컬래값들의 버텍스 당 사용량을 또 한번 대단히 줄여 메시 셰이딩 파이프라인뿐만 아니라 기존의 스탠다드 파이프라인에서도 이점을 볼 수 있습니다.
정점들과 프리미티브의 추가적인 속성들이 다음과 같이 정의됩니다.
out gl_MeshPerVertexNV {
vec4 gl_Position;
float gl_PointSize;
float gl_ClipDistance[];
float gl_CullDistance[];
} gl_MeshVerticesNV[]; // [max_vertices]
// 각자의 사용자 버텍스 출력 블록을 정의합니다.
out Interpolant {
vec2 uv;
} OUT[]; // [max_vertices]
// 특별한 목적의 프리미티브 당 출력
perprimitiveNV out gl_MeshPerPrimitiveNV {
int gl_PrimitiveID;
int gl_Layer;
int gl_ViewportIndex;
int gl_ViewportMask[]; // [1]
} gl_MeshPrimitivesNV[]; // [max_primitives]
하나의 목표는 가장 적은 수의 메시렛들을 가지는 것이며, 메시렛들 내에서 정점들의 재사용률을 최대화하고 할당의 낭비를 줄이는 것입니다. 메시렛 데이터를 생성하기에 앞서서 인덱스 버퍼의 정점 캐시 최적화를 진행하는 것이 이득일 수 있습니다. 예를 들어, Tom Forsyth의 리니어 스피드 최적화가 이를 위해 사용될 수 있겠습니다. 메시 셰이더를 사용할 때 원본 삼각형들의 순서(Ordering)이 유지되니까 인덱스 버퍼로 정점들의 위치를 최적화하는것도 이득을 볼 수 있습니다. CAD 모델들은 종종 '자연스럽게' 스트립으로 생성되기 때문에 이미 좋은 데이터 인접성을 갖고있습니다. 인덱스 버퍼들을 변경하면 메시렛의 클러스터 컬링 프로퍼티에 부정적인 부가 효과를 가져올 수 있습니다. (태스크 레벨의 컬링을 살펴보세요.)
미리 계산된 메시렛
예시로, 저희는 인덱스 버퍼들이 여러 프레임에 걸쳐 바뀌지 않는 상황에서의 정적인 콘텐츠를 렌더링 했습니다. 그러면 디바이스 메모리에 정점들과 인덱스를 업로드 할 때 메시렛 데이터 생성의 비용은 거의 없다시피합니다. 또, 정점 데이터가 매우 정적이게 되면(버텍스 애니메이션이 없고 버텍스 위치 변경이 없는 등), 미리 계산된 데이터를 이용해서 전체 메시렛들을 빠르게 컬링하는데 매우 유용해지고 이점을 볼 수 있게 됩니다.
데이터 구조
저희가 곧 제공할 샘플에 보면, 제공된 인덱스들을 스캔하고 크기 제한(정점이나 프리미티브 개수 제한)에 걸릴 때 마다 새로운 메시렛을 생성하는 기본적인 구현이 담긴 메시렛 빌더가 포함되어 있습니다.
인풋 삼각형 메시는 다음과 같은 데이터를 생성합니다.
struct MeshletDesc {
uint32_t vertexCount; // 사용되는 정점 수
uint32_t primCount; // 사용되는 프리미티브(삼각형) 수
uint32_t vertexBegin; // 버텍스 인덱스 오프셋
uint32_t primBegin; // 프리미티브 인덱스 오프셋
}
std::vector<MeshletDesc> meshletInfos;
std::vector<uint8_t> primitiveIndices;
// short로 충분하면 uint16_t를 사용해도 됩니다.
std::vector<uint32_t> vertexIndices;
왜 두 개의 인덱스 버퍼가 있을까요?
원본 삼각형의 인덱스 버퍼 시퀀스를 보세요.
// 첫 2개의 삼각형을 살펴봅시다.
triangleIndices = { 4,5,6, 8,4,6, ... }
는 2개의 새로운 인덱스 버퍼들로 쪼개집니다.
저희는 삼각형의 인덱스를 루프 돌면서 고유한(유니크한) 정점 인덱스들의 셋을 만들었습니다. 이 작업은 정점 중복 제거 작업이라고도 알려져 있습니다.
vertexIndices = { 4,5,6, 8, ... }
// 두 번째 삼각형은 정점 8만을 갖고있습니다.
// 다른 정점들은 재사용됩니다.
이 프리미티브 정점들은 vertexIndices 에 상대적으로 조절됩니다.
// 원본 데이터
triangleIndices = {4,5,6, 8,4,6, ... }
// 새 데이터
primitiveIndices = {0,1,2, 3,0,2, ... }
// 프리미티브 인덱스들은 각 메시렛에 대해 지역적입니다.(로컬)
한 번 적절한 크기의 제한에 걸리게 되면(너무 많은 고유 정점이나 프리미티브) 새로운 메시렛이 시작됩니다. 그 이후의 메시렛들은 그들만의 고유한 정점들로 정점 셋을 생성할 것입니다.
렌더링 리소스와 데이터 흐름
렌더링 되는 동안 저희는 원본 정점 버퍼를 사용합니다. 그러나 원본 삼각형 인덱스버퍼 대신에 세 가지의 새로운 버퍼를 사용합니다. Figure 8 에서 확인할 수 있습니다.
- Vertex Index Buffer 정점 인덱스 버퍼
정점 인덱스 버퍼는 위에서 이미 설명했습니다. 각 메시렛은 고유한 정점들을 참조합니다. 이 정점들의 인덱스들은 순차적으로 모든 메시렛들의 버퍼에 보관됩니다.
- Primitive Index Buffer 프리미티브 인덱스 버퍼
프리미티브 인덱스 버퍼는 위에서 이미 설명했습니다. 각 메시렛은 프리미티브들의 수를 나타냅니다. 모든 삼각형은 싱글 버퍼에 보관되어있는 3개의 프리미티브 인덱스를 필요로합니다.
메모 : 각 메시렛 다음에 4바이트의 정렬시키기 위해 여분의 정점이 추가될 수도 있습니다.
- Meshlet Desc Buffer 메시렛 디스크립션 버퍼
각 메시렛에 대한 버퍼 오프셋들과 워크로드 정보, 클러스터 컬링 정보를 보관하고 있습니다.
이 3가지 버퍼들은 메시 셰이딩이 허용하는 높은 정점 재사용 때문에 실제로 원본 인덱스 버퍼보다 작습니다. 저희가 확인한 바로는 대략 원본 인덱스 버퍼의 75% 정도로 감소합니다.
- Meshlet Vertices 메시렛 정점들
vertexBegin은 우리가 어디서부터 정점 인덱스를 가져와야할지 시작하는 위치를 보관합니다. vertexCount는 연관되어있는 연속된 정점들의 수를 보관합니다. 이 정점들은 메시렛 내에서 고유하며, 중복되는 인덱스 값이 없습니다.
- Meshlet Primitives 메시렛 프리미티브들
primBegin은 우리가 어디서부터 프리미티브 인덱스를 가져와야할지 시작하는 위치를 보관합니다. primCount는 메시렛 내에서 연관된 프리미티브의 개수를 보관합니다. 유의할 점은 인덱스들의 수는 프리미티브 타입에 의존(삼각형이 경우 3)한다는 것입니다. 이처럼 인덱스들이 vertexBegin에 상대적인 버텍스들을 참조하고 있으며 인덱스 '0'은 vertexBegin에 보관된 정점 인덱스일 거라는 사실은 꽤 중요합니다.
다음 수도코드가 각 메시 셰이더 워크 그룹이 원칙적으로 수행하는 것을 묘사합니다. 보여주기 위한 예시 코드입니다.
// 이 코드는 그저 의사(수도) 코드입니다.
// 작업그룹의 로컬 스레드 호출을 사용하는 실제 GLSL 코드가 아닙니다.
for (int v = 0; v < meshlet.vertexCount; v++) {
int vertexIndex = texelFetch(vertexIndexBuffer, meshlet.vertexBegin + v).x;
vec4 vertex = texelFetch(vertexBuffer, vertexIndex);
gl_MeshVerticesNV[v].gl_Position = transform * vertex;
}
for (int p = 0; p < meshlet.primCount; p++) {
uvec3 triangle = getTriIndices(primitiveIndexBuffer, meshlet.primBegin + p);
gl_PrimtiveIndicesNV[p * 3 + 0] = triangle.x;
gl_PrimtiveIndicesNV[p * 3 + 1] = triangle.y;
gl_PrimtiveIndicesNV[p * 3 + 2] = triangle.z;
}
// 하나의 스레드가 아웃풋 프리미티브를 만듭니다.
gl_PrimitiveCountNV = meshlet.primCount;
이 메시 셰이더는 다음과 같이 병렬적인 방식으로 작성될 수 있습니다.
void main() {
...
// resolved at compile time
const uint vertexLoops =
(MAX_VERTEX_COUNT + GROUP_SIZE - 1) / GROUP_SIZE;
for (uint loop = 0; loop < vertexLoops; loop++) {
// 스레드간에 작업을 나눔
uint v = gl_LocalInvocationID.x + loop * GROUP_SIZE;
//
v = min(v, meshlet.vertexCount - 1);
{
int vertexIndex = texelFetch(vertexIndexBuffer, int(meshlet.vertexBegin + v)).x;
vec4 vertex = texelFetch(vertexBuffer, vertexIndex);
gl_MeshVerticesNV[v].gl_Position = transform * vertex;
}
}
//
uint primreadBegin = meshlet.primBegin / 8;
uint primreadIndex = meshlet.primCount * 3 - 1;
uint primreadMax = primreadIndex / 8;
const uint primreadLoops =
(MAX_PRIMITIVE_COUNT * 3 + GROUP_SIZE * 8 - 1) / (GROUP_SIZE * 8);
for (uint loop = 0; loop < primreadLoops; loop++) {
uint p = gl_LocalInvocationID.x + loop * GROUP_SIZE;
p = min(p, primreadMax);
uvec2 topology = texelFetch(primitiveIndexBuffer, int(primreadBegin + p)).rg;
writePackedPrimitiveIndices4x8NV(p * 8 + 0, topology.x);
writePackedPrimitiveIndices4x8NV(p * 8 + 4, topology.y);
}
if (gl_LocalInvocationID.x == 0) {
gl_PrimitiveCountNV = meshlet.primCount;
}
}
이 예제는 그냥 직관적인 구현에 불과합니다. 개발자에 의해 수행되는 모든 데이터 가져오기 때문에 커스텀 인코딩, 서브그룹이나 공유 메모리를 통한 압축 해제 또는 정점 출력을 임시로 사용해 추가적으로 대역폭을 절약할 수 있습니다.
태스크 셰이더를 이용한 클러스터 컬링
저희는 이른 타이밍의 컬링을 수행하기 위해 메시렛 디스크립터에 더 많은 정보를 쥐어 짜 넣으려고 노력했습니다. 저희는 이전에 언급한 값들을 인코딩하는 128비트 디스크립터를 이용해서 박스와 콘의 뒷면 클러스터 컬링을 수행하는 실험을 수행했습니다. 메시렛을 생성할 때 필요한 것 하나는 클러스터 컬링 프로퍼티와 개선된 버텍스 재사용 방식의 적절한 균형입니다. 하나는 다른 하나에게 악영향을 미칠 수도 있습니다.
이 아래의 태스크 셰이더는 32 메시렛까지 컬링합니다.
layout(local_size_x=32) in;
taskNV out Task {
uint baseID;
uint8_t subIDs[GROUP_SIZE];
} OUT;
void main() {
uvec4 desc = meshletDescs[gl_GlobalInvocationID.x];
bool render = gl_GlobalInvocationID.x < meshletCount && !earlyCull(desc);
uvec4 vote = subgroupBallot(render);
uint tasks = subgroupBallotBitCount(vote);
if (gl_LocalInvocationID.x == 0) {
gl_TaskCountNV = tasks;
OUT.baseID = gl_WorkGroupID.x * GROUP_SIZE;
}
{
uint idxOffset = subgrouopBallotExclusiveBitCount(vote);
if (render) {
OUT.subIDs[idxOffset] = uint8_t(gl_LocalInvocationID.x);
}
}
}
이 메시 셰이더는 어떤 메시렛을 생성해야하는지 식별하기 위해 이제 태스크 셰이더에서의 정보를 사용할 수 있다.
taskNV in Task {
uint baseID;
uint8_t subIDs[GROUP_SIZE];
} IN;
void main() {
uint meshletID = IN.baseID + IN.subIDs[gl_WorkGroupID.x];
uvec4 desc = meshletDescs[meshletID];
...
}
저희는 태스크 섀이더 안에서만 메시렛을 컬링했습니다. 큰 삼각형 모델을 그린다는 맥락에서요. 이외에도 LOD 결정에 의존해 다른 메시렛 데이터를 고르거나, 지오메트리를 완전히 다 생성해버리는 방법을 택할 수도 있습니다. 아래 Figure 9는 태스크 셰이더를 이용해 LOD 계산을 하는 데모에서 가져왔습니다.
결론
몇 가지 주요사항입니다.
- 삼각형 메시는 인덱스 버퍼를 한 번 스캔함으로써 메시렛으로 변환 가능합니다. 기존 렌더링 파이프라인에서 도움이 되던 정점 캐시 최적화 도구는 메시렛 패킹을 효율적으로 하는 데에도 도움을 줍니다. 더 정교한 클러스터링은 태스크 셰이더 단계에서 개선된 이른 렌더링 거부를 할 수 있습니다. (일관적인 삼각형 노멀이나 타이트한 바운딩박스)
- 태스크 셰이더는 하드웨어가 메시 셰이더로 정점, 프리미티브 메모리를 할당하기 전에 일찍 프리미티브 그룹을 건너뛰게 할 수 있습니다. 또 필요한 경우 하나 이상의 자식 호출을 발생시킬 수 있습니다.
- 정점들은 원래의 버텍스 셰이더처럼 작업 그룹들의 스레드에서 병렬적으로 작업될 수 있습니다.
- 정점 셰이더들은 몇 개의 전처리기 추가로 메시 셰이더에 적합하게 작동하도록 만들 수 있습니다.
- 더 많은 정점 재사용을 위해 데이터를 더 적게 가져올 필요가 있습니다. (기존의 정점 셰이더는 버텍스 32개와 프리미티브 32개 제한을 갖고 있었습니다.). 평균적인 트라이앵글 메시의 결합은 삼각형의 수 두 배를 정점으로 사용하는것이 이득이라고 보여줍니다.
- 모든 데이터 로드는 기존의 고정된 함수로 프리미티브를 가져오는 대신에 셰이더 인스트럭션을 통해 이루어지며 더 많은 멀티프로세서를 스트리밍 할 때 이롭게 확장됩니다. 또한, 대역폭을 줄이기 위한 커스텀 정점 인코딩 사용을 쉽게 만들어 줍니다.
- 정점 속성을 많이 사용하는 경우에는 프리미티브 컬링 단계가 병렬적으로 수행되는것이 유리할 수 있습니다. 이렇게 하면 컬링되서 렌더링 되지 않을 프리미티브들의 정점 데이터를 로딩하지 않고 건너뛸 수 있게 해주기 때문입니다. 그러나 최고의 이점은 태스크 레벨에서 효과적으로 컬링된다는 점입니다.
주로 태스크 셰이더가 컬링을 진행할 수 있고, 메시 셰이더에서 처리해야할 양이 많은 경우 그 컬링의 효과가 극대화됩니다. 데이터를 넘기거나 처리해야하는 양이 줄어들기 때문입니다. 또한 메시 셰이더에서 컬링 작업도 처리하게 될텐데 그 부분에서도 미리 진행되므로 상당한 이득을 볼 수 있습니다.
(이하 생략)
언리얼 엔진 5의 나나이트와 연관지어서 생각
나나이트가 메시셰이더를 활용하여 클러스터 단위로 작업을 거치는 것은 알겠지만 이 기술로 처리되지는 않았을거라 생각됩니다. 많은 정점과 트라이앵글을 처리하는 것 뿐만 아니라, 클러스터 단위로 LOD를 결정하고 렌더링하며 SSD에서 데이터를 온디맨드 방식으로 스트리밍해서 로드하기 때문에 대역폭의 한계를 극복한 디테일한 프로세스에 대해 엔진 코드를 분석하며 확인해보아야겠습니다. 시간을 내어 조금씩 진행하고는 있지만 루멘과 같은 다른 렌더링 기능들이 얽혀 있어 분석하는데 시간이 꽤 걸리고 진전이 느리게 느껴지네요.
한편, 유니티에서도 태스크 셰이더와 메시 셰이더를 사용해 렌더링을 구성할 수 있을지 궁금해집니다.
'Unreal > Articles' 카테고리의 다른 글
언리얼: Module Chronicle 1 - 모듈의 이해 (0) | 2022.01.16 |
---|---|
언리얼: 에인션트의 협곡 골렘 구현 방식 간단 살펴보기 (0) | 2021.05.31 |
언리얼: 루멘 기술 개요 한국어 번역 (0) | 2021.05.31 |
언리얼: 언리얼 엔진 5 개인 공부 자료 및 해석 (0) | 2021.05.31 |
언리얼: 메타 휴먼 크리에이터 EA 신청 및 소스 파일 다운로드(MAYA) (0) | 2021.05.31 |