TECH.ART.FLOW.IO

[번역]Efficient GPU Rendering for Dynamic Instances in Game Development

jplee 2024. 2. 5. 11:39

역자의 말.

아트디렉터 경험부터 테크니컬 아티스트 경험까지 다양한 경험을 갖고 있는 Kacper Szwajka 의 흥미롭고 실험적인 두 개의 토픽을 소개 하려고 합니다.


원문.

 

Efficient GPU Rendering for Dynamic Instances in Game Development

This article explores a custom rendering architecture designed to efficiently render procedurally generated geometry. Our focus is on…

medium.com

이 토픽에서는 절차적으로 생성된 지오메트리를 효율적으로 렌더링하도록 설계된 커스텀 렌더링 아키텍처를 살펴봅니다. 이 글에서는 컴퓨팅 셰이더를 설정하고 이를 더 적은 수의 머티리얼 패스로 병합하여 배치 수를 최적화하는 데 중점을 둡니다. 이 접근 방식은 전체 시스템의 메모리 할당을 최소화하는 것을 목표로 합니다. 간접 렌더링과 GPU 인스턴싱의 기본 사항은 이미 많은 내용들이 인터넷에 이미 있으므로 생략하겠습니다.

All visible instances were rendered with described architecture

가정: 고정된 수의 변환에 가능한 한 최소한의 데이터를 사용하되, 다양한 유형의 게임 요소('프리팹'이라고 함)를 유연하게 처리하는 것을 목표로 하는 시스템으로 작업하고 있습니다. 컬링, LOD 선택, 데이터 압축과 같은 대부분의 프로세스를 GPU에서 처리하여 모든 것을 최대한 효율적으로 렌더링하는 것이 목표입니다.

프로토타입
프로토타입은 게임 디자인에서 조립식과 비슷한 개념이라고 생각하면 됩니다. 프로토타입은 모든 LOD(레벨 오브 디테일) 레벨과 각각의 서브메시 및 머티리얼이 포함된 패키지입니다. 이 추가 추상화 계층을 통해 더 많은 제어가 가능하며, 기존 Unity 렌더링에서는 까다롭거나 노동 집약적인 몇 가지 깔끔한 트릭을 사용할 수 있습니다.

커스터마이징의 예시:

  • 그림자용 커스텀 머티리얼: 식생에서 알파 클립을 제외하거나 모든 메시 그림자를 단일 머티리얼로 그리면 일괄 처리를 크게 개선할 수 있습니다.
  • LOD 임계값에 걸쳐 그림자 렌더링: 프로토타입의 마지막 LOD 레벨 메시를 사용하여 모든 LOD 거리에 대한 그림자를 렌더링할 수 있습니다. 이 접근 방식은 버텍스 수를 줄이고 일괄 처리를 더욱 개선합니다.

첫 번째 구현 시도
GPU 기반 렌더링 엔진과 같은 기사에서 영감을 받아 배치 수를 줄이고 머티리얼 패스를 최적화하는 데 중점을 두었으며, 기본적으로 하나의 큰 커멘드 버퍼로 전체 씬을 렌더링하는 것을 목표로 했습니다.

이 아키텍처는 다음과 같은 방식으로 작동했습니다:

  1. 각 유형별로 몇 개의 프로토타입이 필요한지 파악하고 그에 맞는 버퍼를 할당해야 합니다. 필요한 모든 프로토타입을 결합하여 하나의 큰 변환 및 가시성 버퍼를 할당합니다.
    또한 각 버퍼의 시작/끝을 표시하여 나중에 접두사 합산 알고리즘을 기반으로 각 유형의 프로토타입 수를 읽는 데 사용할 수 있도록 합니다.
  2. 특정 프로토타입이 가시성(일부 외부 클래스에 기반)인지 표시한 다음 접두사 합계 + 압축을 수행하여 각 유형별로 얼마나 많은 프로토타입을 그려야 하는지 파악합니다.
  3. 그런 다음 이 데이터를 배치 버퍼에 넣은 다음 커멘드 버퍼에 넣습니다.

제가 알기로는 이 방법은 꽤 표준적인 방법이며 이미 이에 대한 훌륭한 논문이 많이 있습니다.

이 방법은 정적 환경에서는 효과가 있지만 프로토타입 유형을 동적으로 변경하려는 경우 문제가 시작됩니다. 제가 주로 사용했던 사례는 제가 작업하던 동적 TerrainInstancer에 이 아키텍처를 사용하는 것이었습니다. 자세한 내용은 여기에서 확인할 수 있습니다:

 

GPU Run-time Procedural Placement on Terrain

In this article, I will explain the method for implementing run-time GPU placement of objects on terrain, using Unity engine. This…

medium.com

얼마나 많은 트랜스폼이 나올지 예측할 수만 있다면요. 구체적인 프로토타입의 수는 알 수 없으며 인스턴서를 업데이트할 때마다 변경될 수 있습니다.
첫 번째 시도에서는 가능한 모든 경우에 대해 버퍼를 할당합니다. 예를 들어 1000 개의 위치에 10 가지 유형의 나무를 스폰하려면 (나무 유형은 무작위로 선택됨) 실제로 10000 개의 나무에 대한 데이터를 할당하고 모든 접두사 합산 / 압축을 수행해야합니다. 이 솔루션은 프로토타입 수가 증가함에 따라 확장성이 떨어집니다.

해결책을 찾았습니다!
이 알고리즘의 가장 큰 특징 중 하나는 효율성, 특히 얼마나 많은 프로토타입이 포함되더라도 메모리 할당을 일정하게 유지하는 방식입니다.
더 나은 이해를 위해 이 과정을 단계별로 살펴보겠습니다.

1. 프로토타입 선택 / 컬링
프로토타입 인덱스를 선택하고 트랜스폼 버퍼를 설정합니다. 이 버퍼는 1=1을 트랜스폼 버퍼에 매핑하고 선택된 프로토타입 인덱스를 저장합니다. 더 나은 메모리 저장을 위해 이 선택된 프로토타입 버퍼를 다음과 같은 방식으로 패킹합니다: 16비트 선택 프로토타입, 14비트 로드 값, 2비트 가시성 (GBuffer, Shadow). 16비트 최대값은 65,536이며, 이는 배치 그룹에 포함할 수 있는 프로토타입 유형의 최대 개수입니다. 14비트 로드 값과 2비트 가시성은 나중에 사용됩니다.
그런 다음 프러스텀 컬링(계층형-Z 맵 오클루전도 여기에 구현될 수 있음)과 로드 피킹을 수행합니다. 컬링은 GBuffer와 그림자 패스에 대해 별도로 수행해야 하므로 가시성을 2비트로 분리하여 저장합니다.
또한 방향성 그림자 컬링만 지원한다는 점도 언급할 가치가 있습니다. 선택된 로드는 14비트 값(0.0-4.0)으로 저장되며, 여기서 4는 지원되는 최대 로드 값입니다. 이 값을 트랜지션 값으로 저장하여 로드 크로스 페이드 기능을 지원합니다.


2. 가시성 전송
여기서는 선택 로드를 기반으로 "GPU_PROTOTYPE_LOD" 버퍼를 읽어 현재 로드 레벨에서 렌더링해야 하는 메시를 파악합니다.

3. 프로토타입에 대한 LOD 메시 선택하기
첫 번째 단계에서 프로토타입 5를 선택한 다음, 계산 셰이더가 프로토타입 5의 파마를 읽고 해당 BBox 위치와 크기를 기반으로 컬링 + LOD 피킹을 수행한다고 가정해 보겠습니다.
그런 다음 이 선택된 로드를 기반으로 렌더링해야 하는 메시(배치 인덱스)를 읽고 해당 인덱스를 설정합니다.
참고: 이 " instancePickBatchBuffer "의 크기는 프로토타입 로드 레벨의 최대 서브메시 수에 따라 달라집니다. 제 구현에서는 GBuffer 렌더링에 최대 4개의 서브 메시를 저장하고 그림자 패스에 4개의 서브 메시를 저장할 수 있다고 가정합니다(다를 수 있음).

4. 정렬
마지막 단계 이후 ' instancePickBatchBuffer '의 순서가 매우 혼란스럽고 각 유형의 메시를 몇 개 렌더링해야 하는지 여전히 알 수 없습니다. 그래서 소팅을 수행합니다.

5. 배치 카운팅 및 명령 버퍼 준비
정렬이 수행될 때 각 배치 수를 계산하기 위해 몇 가지 단계를 수행한 다음 이 데이터를 하나의 큰 명령 버퍼로 변환하여 IndirectDrawingCall에 사용합니다.
- 그 위에 셰이더를 디스패치하고 배치 시작 - 끝을 검색합니다. 다음은 이를 수행하는 예제 코드입니다:

[numthreads(GPUI_THREADS,1,1)]
void MarkLaneStart (uint3 id : SV_DispatchThreadID)
{
  if(id.x >= maxInstances) return;

  uint refIndex = batchIndexesRef[id.x];
  uint prevRefIndex = batchIndexesRef[id.x-1];

  uint pickBatch = batchIndexes[refIndex];
  if(pickBatch >= 0xFFFF) return; // Invalid
   
  if((pickBatch != batchIndexes[prevRefIndex] || id.x ==0))
  {
    batches[pickBatch].start = id.x;
    batchesVisibility[pickBatch] = 1;
  }
}
[numthreads(GPUI_THREADS,1,1)]
void MarkLaneEnd (uint3 id : SV_DispatchThreadID)
{
  if(id.x >= maxInstances) return;

  uint refIndex = batchIndexesRef[id.x];
  uint nextRefIndex = batchIndexesRef[id.x+1];

  uint pickBatch = batchIndexes[refIndex];
  if(pickBatch >= 0xFFFF) return; // Invalid
   
  if((pickBatch != batchIndexes[nextRefIndex] || id.x+1 >= maxInstances ))
  {
    batches[pickBatch].end = id.x;
  }
}

그런 다음 모든 배치를 반복하여 크기를 계산하고 이 데이터를 명령 버퍼로 전달합니다.

참고: 이 두 커널은 하나로 쉽게 병합할 수 있지만 읽기 쉽도록 그대로 두었습니다. 99,9%의 bootleneck이 정렬 패스에 있기 때문에 어쨌든 성능 손실은 0입니다.

GPU에서 정렬
현재 시스템에서 가장 큰 부트넥 중 하나는 정렬입니다. 이 프로세스와 이를 최적화하는 방법을 설명하는 훌륭한 논문이 이미 많이 있습니다.
저는 여전히 새로운 옵션을 테스트 중이지만 아마도 가장 큰 변화는 DX12로 전환하고 WaveIntrinsics 및 기타 메모리 액세스 트릭의 힘을 사용하는 것입니다. 정렬을 위한 최고의 알고리즘 중 하나를 구현하고 성능을 테스트하는 이 리포지토리를 발견했습니다:

 

GitHub - b0nes164/ShaderOneSweep: A compute shader implementation of the OneSweep sorting algorithm.

A compute shader implementation of the OneSweep sorting algorithm. - GitHub - b0nes164/ShaderOneSweep: A compute shader implementation of the OneSweep sorting algorithm.

github.com

My current implementation is based on BitonicSort :

 

GitHub - nobnak/GPUMergeSortForUnity

Contribute to nobnak/GPUMergeSortForUnity development by creating an account on GitHub.

github.com

스토리지 혁신
앞서 언급했듯이 가장 큰 병목 현상 중 하나는 메모리 대역폭입니다.
첫 번째 시도는 행렬 변환을 최적화하는 것이었고, 유니티에서 BRG에서 사용하는 float4x4 대신 flaot4x3을 사용했습니다.

 

BatchRendererGroup sample: Achieve high frame rate even on budget devices | Unity Blog

Then, fill the draw commands. Each BatchDrawCommand contains a meshID, batchID (to know how to use metadata), and materialID. It also contains the starting offset in the visibility int array buffer. As we don't need any frustum culling in our context, we f

blog.unity.com

또 다른 최적화는 WorldToObject 행렬을 저장하지 않고 셰이더에서 계산하는 대신에 저장하는 것입니다.
월드투오브젝트 매트릭스는 주로 조명 계산에 필요한 것으로 알고 있으므로 일부 파이프라인에서는 이를 생략할 수 있습니다.


제이슨 부스 덕분에  최적화 솔루션 위치, 회전, 배율을 float3와 uint3에 담을 수 있었습니다. 위치는 float3에 저장하고 배율과 회전은 uint3에 저장합니다.

 

Discussion - GPU Driven Rendering

Hey, I've recently been working on implementing a 'GPU Rendering Pipeline', aiming to maximize GPU utilization and minimize Draw Calls through...

forum.unity.com

정말 대단하네요. 예를 들어 1,000,000개의 트랜스폼을 float4x4에 저장하려면 약 64Mb가 필요하지만, 최종 패킹 방법을 사용하면 약 24Mb만 소요됩니다.

기타 메모리 최적화
스케일링 및 회전 외에도 트랜스폼 인덱스 선택 및 LOD 크로스 페이드 값과 같은 다른 파라미터도 패킹되어 메모리 사용량을 더욱 최적화합니다. 최종 버퍼와 그 패킹 설명은 다음과 같습니다:

드로잉 메서드
Unity를 사용하는 경우 "Graphics.DrawMeshInstancedIndirect()" 대신 "Graphics.RenderMeshIndirect()"를 사용하는 것만 기억해 주세요. 이에 대한 자세한 내용은 Unity 포럼에서 쉽게 확인할 수 있습니다.

더 나은 방법은 없을까요?

  • 많은 메시를 한 번에 그리기: 제가 구현한 Unity 엔진은 동일한 머티리얼로 많은 메시를 그리는 적절한 방법을 완전히 지원하지 않는 것으로 알고 있습니다. (아마도 향후에). 렌더링 문서에 따르면 유니티는 각 메쉬를 다른 배치로 분할하고 이러한 메쉬에 대한 데이터를 한 번만 전달합니다. (다시 쓰기)보다 여전히 낫습니다. 다음은 이를 보여주는 하나의 스레드입니다:
 

GPU driven rendering with SRP: No DrawProceduralIndirectNow for CommandBuffers?

I am currently in the process of writing a custom GPU driven SRP and have a problem with reducing SetPass calls as there seem to be no methods for...

forum.unity.com

  • 머티리얼 병합 및 텍스처 배열 사용 : 다음 단계에서 개선하려고 하는 부분입니다. 머티리얼 파마를 버퍼에 병합하고 텍스처를 텍스처 배열에 저장한 다음 오브젝트를 그릴 때 배열에서 가져올 페이지를 선택하는 것입니다. 문제는 이 방법을 유니티에서 "깔끔한" 방식으로 구현하는 것이 상당히 어렵다는 것입니다. 제가 알기로는 Unity에 텍스처를 강제로 로드/언로드할 수 있는 직접적인 API가 없습니다. ||
    예를 들어 병합해야 하는 모든 머티리얼을 수집한다고 가정해 보겠습니다. 각 머티리얼에는 3개의 텍스처가 있으므로 결과적으로 3개의 텍스처 배열이 있어야 합니다. 코드 어딘가에 이전 머티리얼에 대한 참조를 하나만 남겨두면 99%의 경우 이 머티리얼의 텍스처가 메모리에 로드됩니다.) 이 경우 지오메트리 자체를 그리는 것이 실제로는 더 저렴하지만 메모리 소비는 실질적으로 두 배가 될 것입니다.
    이 작업을 수행하는 "적절한" 방법을 알고 싶습니다.
  • 메시 클러스터 : 이 기술은 지난 몇 년 동안 점점 더 인기를 얻고 있습니다. 아마도 첫 번째 대규모 구현은 여기에서 찾을 수 있을 것입니다: 

https://advances.realtimerendering.com/s2015/aaltonenhaar_siggraph2015_combined_final_footer_220dpi.pdf