Unity 검색

BatchRendererGroup 샘플: 저사양 기기에서도 높은 프레임 속도 구현하기

2023년 10월 3일 엔진 & 플랫폼 | 15 분 소요
A side-by-side look at a still image of the BatchRendererGroup (BRG) shooter sample in action on a horizontal smartphone next to code for the sample.
A side-by-side look at a still image of the BatchRendererGroup (BRG) shooter sample in action on a horizontal smartphone next to code for the sample.
공유

Is this article helpful for you?

Thank you for your feedback!

이 글에서는 여러 가지 인터랙티브 오브젝트를 애니메이션화하고 렌더링하는 간단한 슈팅 게임 샘플을 살펴봅니다. 상당수의 데모는 고사양 PC만 타게팅하지만, 우리의 이번 목표는 GLES 3.0을 사용해 저사양 휴대폰에서 높은 프레임 속도를 구현하는 것입니다. 이 샘플은 BatchRendererGroup, 버스트 컴파일러, C# 잡 시스템을 사용합니다. Unity 2022.3에서 실행할 수 있으며 엔티티 또는 entities.graphics DOTS 패키지를 요구하지 않습니다.

그럼 시작해 보겠습니다.

샘플 소개

먼저 샘플을 소개하겠습니다. 이 샘플은 저사양의 2019 삼성 갤럭시 A51(Mali G72-MP3 GPU 사용)에서 60fps를 안정적으로 유지하며 실행됩니다. 그래픽스 API는 GLES 3.0으로 설정되어 있습니다.

GitHub에서 샘플 프로젝트를 다운로드하여 원하는 플랫폼에서 코드를 살펴보실 수 있습니다. Unity 2022.3만 설치해 두면 됩니다.

이 글에서는 BatchRendererGroup과 샘플 클래스 BRG_Container.cs를 중점적으로 살펴보겠습니다. 애니메이션 및 물리 코드는 BRG_Background.csBRG_Debris.cs 클래스에서 자세히 살펴보실 수 있습니다.

기본 정보

구체적인 제작 방법을 살펴보기 전에 샘플의 특성을 살펴봅시다.

  • 배경의 바닥은 수많은 큐브로 구성되며 모든 큐브는 위아래로 움직이도록 애니메이션화되어 있습니다.
  • 비행선은 수평 방향으로 화면을 이동하면서 다양한 색상의 구체에 미사일을 발사합니다. (화면을 탭하면 미사일이 더 빠르게 발사됩니다.)
  • 미사일이 바닥 위로 날아가면 그 바닥을 구성하는 셀이 자기장에 의해 약간 솟구치며 강조 표시됩니다. 바닥의 파편도 공중으로 흩뿌려집니다.
  • 미사일이 타격한 구체는 폭발하며 다양한 색상의 파편으로 흩어집니다.
  • 떨어진 파편이 바닥에 닿으면 그와 접촉하는 셀이 일시적으로 하얗게 빛납니다. 셀이 더 많은 파편과 접촉할수록 셀의 색상은 더욱 어두워집니다. 바닥은 파편의 무게만큼 아래로 꺼집니다.

렌더링

바닥의 셀과 파편은 모두 큐브로 구성되며 각각의 큐브는 색상과 위치가 서로 다릅니다. CPU로 모든 요소를 애니메이션화하고 관리하여 바닥과 파편 간의 상호 작용을 더욱 단순화해 봅시다. (파편은 단순한 시각 효과가 아니기 때문에 GPU만으로 구현할 수 없습니다.)

렌더링 측면에서는 저사양 모바일 기기에서 불필요한 성능 저하가 발생하지 않도록 항목별 게임 오브젝트를 생성하지 않습니다. 대신 새로 도입된 BatchRendererGroup API를 사용할 것입니다.

기존의 Graphics.DrawMeshInstanced를 사용하지 않는 이유

Graphics.DrawMeshInstanced를 사용하면 서로 유사하지만 위치가 다른 다수의 메시를 빠르고 편리하게 렌더링할 수 있습니다. 하지만 BatchRendererGroup API에 비하면 다음과 같은 단점을 갖고 있습니다.

  • 관리형 메모리 배열에 행렬을 제공해야 하므로 가비지 컬렉션이 발생할 수 있습니다. URP나 언릿 셰이더처럼 역행렬이 불필요한 환경에서도 CPU가 역행렬을 계산합니다.
  • obj2world 행렬 이외의 프로퍼티(예: 인스턴스별 하나의 색상)를 커스터마이즈하려면 처음부터 셰이더를 작성하거나 셰이더 그래프를 사용하여 자체적인 커스텀 셰이더를 제공해야 합니다.
  • 드로우를 호출할 때마다 행렬 또는 커스텀 데이터를 GPU 메모리에 업로드해야 합니다. Graphics.DrawMeshInstanced로는 GPU 메모리에 데이터를 지속해서 저장할 수 없으며 이는 상황에 따라 심각한 성능 저하를 일으킬 수 있습니다.

BatchRendererGroup이란?

BRG(BatchRendererGroup)는 C#에서 드로우 커맨드를 효율적으로 생성하며 GPU 인스턴싱 드로우 콜을 생성하는 API입니다. 관리형 메모리를 사용하지 않으므로 버스트 컴파일러로 커맨드를 생성할 수도 있습니다.

장점단점
Burst 잡에서 DrawInstanced 커맨드를 신속하게 생성할 수 있음드로우 커맨드의 최적의 배치를 직접 생성해야 함
지속적으로 유지되는 대규모 GPU 버퍼를 사용해서 인스턴스별로 모든 커스텀 프로퍼티를 저장함GPU 메모리와 커스텀 프로퍼티 오프셋 할당을 반드시 직접 관리해야 함
OpenGLES 3.0 이상을 포함한 다양한 플랫폼에서 지원됨 
표준 SRP 셰이더(릿 및 언릿)와 호환되며 커스텀 셰이더를 작성할 필요가 없음 
장점
Burst 잡에서 DrawInstanced 커맨드를 신속하게 생성할 수 있음
지속적으로 유지되는 대규모 GPU 버퍼를 사용해서 인스턴스별로 모든 커스텀 프로퍼티를 저장함
OpenGLES 3.0 이상을 포함한 다양한 플랫폼에서 지원됨
표준 SRP 셰이더(릿 및 언릿)와 호환되며 커스텀 셰이더를 작성할 필요가 없음
단점
드로우 커맨드의 최적의 배치를 직접 생성해야 함
GPU 메모리와 커스텀 프로퍼티 오프셋 할당을 반드시 직접 관리해야 함

팁: entities.graphics 패키지는 엔티티(ECS 패키지)를 렌더링하기 위한 것으로 BRG를 기반으로 구축되었습니다. entities.package는 모든 GPU 메모리를 관리하고 최적의 드로우 커맨드를 생성합니다. 이 샘플에서는 ECS를 사용하지 않으므로 BRG를 직접 활용하겠습니다.

BRG 셰이더 데이터 모델

BRG는 특정한 GPU 데이터 레이아웃과 전용 셰이더 배리언트를 사용합니다. 셰이더 배리언트는 표준 상수 버퍼(UnityPerMaterial) 또는 대규모의 커스텀 GPU 버퍼(BRG 원시 버퍼)에서 데이터를 가져올 수 있습니다. SSBO(셰이더 스토리지 버퍼 오브젝트 또는 바이트 주소 버퍼)인 원시 버퍼에 데이터를 저장하는 방식은 사용자가 관리해야 합니다. 기본 BRG 데이터 레이아웃은 SoA(배열의 구조체) 유형입니다.

인스턴스당 하나 이상의 프로퍼티

커스텀 셰이더를 만들지 않고도 머티리얼의 모든 프로퍼티를 인스턴스화할 수 있습니다. 이 샘플에서는 obj2world 행렬(큐브 배치), world2obj 행렬(조명), 박스 인스턴스당 BaseColor(각각의 바닥 셀과 파편은 자체적인 색상을 가지므로)를 인스턴스화해야 합니다.

모든 큐브의 다른 프로퍼티는 전부 동일하며(예: 평활도 값) 메타데이터를 사용해서 인스턴스당 커스텀 값을 부여할 프로퍼티를 설명하면 됩니다.

BRG 메타데이터

BRG 메타데이터는 원하는 경우에 셰이더 프로퍼티별로 설정할 수 있는 32비트 값입니다. GPU 메모리의 어느 부분에서 어떻게 프로퍼티 값을 로드해야 하는지 셰이더 코드에 알려 줍니다. 비트 0~30은 BRG 원시 버퍼 내 프로퍼티의 오프셋을 정의하며, 비트 31은 프로퍼티 값이 모든 인스턴스에 동일한지 또는 오프셋이 인스턴스당 하나의 값을 갖는 배열의 시작인지 알려 줍니다.

BRG 메타데이터의 정확한 의미는 셰이더 프로퍼티 유형에 따라서도 달라집니다. 가능한 경우를 모두 요약하면 다음과 같습니다.

셰이더 프로퍼티BRG 메타데이터가 정의되지 않음BRG 메타데이터가 정의됨, 비트 31이 비어 있음BRG 메타데이터가 정의됨, 비트 31이 설정됨
‘머티리얼별’ 프로퍼티(예: ‘BaseColor’)표준 UnityPerMaterial 상수 버퍼표준 UnityPerMaterial 상수 버퍼BRG 원시 버퍼, 배열(인스턴스당 하나의 값)
obj2world, world2obj, MatrixPreviousM, MatrixPreviousM정의되지 않음. 셰이더 배리언트가 이 프로퍼티를 사용한다면 메타데이터를 정의해야 함BRG 원시 버퍼, 모든 인스턴스의 값이 동일함BRG 원시 버퍼, 배열(인스턴스당 하나의 값)
LODFade RenderingLayer MotionVectorsParams WorldTransformParams
Unity에서 자동으로 제공(값을 오버라이드할 수 없음)
SHAx, SHBx, SHC ProbesOcclusionUnity가 자동으로 제공하는 전역 SH, 모든 인스턴스의 값이 동일함BRG 원시 버퍼, 모든 인스턴스의 값이 동일함BRG 원시 버퍼, 배열(인스턴스당 하나의 값)
핵심: 옅은 회색 셀은 각 인스턴스의 값이 다름을, 파란색 셀은 각 인스턴스에 하나의 값이 있음을 나타냅니다.
BRG 메타데이터가 정의되지 않음
‘머티리얼별’ 프로퍼티(예: ‘BaseColor’)표준 UnityPerMaterial 상수 버퍼
obj2world, world2obj, MatrixPreviousM, MatrixPreviousM정의되지 않음. 셰이더 배리언트가 이 프로퍼티를 사용한다면 메타데이터를 정의해야 함
LODFade RenderingLayer MotionVectorsParams WorldTransformParamsUnity에서 자동으로 제공(값을 오버라이드할 수 없음)
SHAx, SHBx, SHC ProbesOcclusionUnity가 자동으로 제공하는 전역 SH, 모든 인스턴스의 값이 동일함
BRG 메타데이터가 정의됨, 비트 31이 비어 있음
‘머티리얼별’ 프로퍼티(예: ‘BaseColor’)표준 UnityPerMaterial 상수 버퍼
obj2world, world2obj, MatrixPreviousM, MatrixPreviousMBRG 원시 버퍼, 모든 인스턴스의 값이 동일함
LODFade RenderingLayer MotionVectorsParams WorldTransformParamsUnity에서 자동으로 제공(값을 오버라이드할 수 없음)
SHAx, SHBx, SHC ProbesOcclusionBRG 원시 버퍼, 모든 인스턴스의 값이 동일함
BRG 메타데이터가 정의됨, 비트 31이 설정됨
‘머티리얼별’ 프로퍼티(예: ‘BaseColor’)BRG 원시 버퍼, 배열(인스턴스당 하나의 값)
obj2world, world2obj, MatrixPreviousM, MatrixPreviousMBRG 원시 버퍼, 배열(인스턴스당 하나의 값)
LODFade RenderingLayer MotionVectorsParams WorldTransformParamsUnity에서 자동으로 제공(값을 오버라이드할 수 없음)
SHAx, SHBx, SHC ProbesOcclusionBRG 원시 버퍼, 배열(인스턴스당 하나의 값)
핵심: 옅은 회색 셀은 각 인스턴스의 값이 다름을, 파란색 셀은 각 인스턴스에 하나의 값이 있음을 나타냅니다.
Figure 1: using BRG metadata you can describe which properties have custom value per instance (like obj2world, world2obj, baseColor). All other properties have the exact same value for all instances (and still use classic UnityPerMaterial cbuffer as the data source).
그림 1: BRG 메타데이터를 사용해서 어떤 프로퍼티가 인스턴스당 커스텀 값을 갖는지 설명할 수 있습니다(obj2world, world2obj, baseColor 등). 나머지 프로퍼티는 모든 인스턴스에서 값이 동일합니다(기존 UnityPerMaterial cbuffer를 여전히 데이터 소스로 사용함).

BRG 컬링과 가시성 인덱스

Graphics.DrawMeshInstanced와 달리 BRG는 지속적으로 유지되는 GPU 메모리 버퍼를 사용합니다. 원시 버퍼에 큐브 10개의 위치와 색상이 존재하지만 큐브 0, 3, 7만 보인다고 가정해 봅시다. 따라서 세 개의 큐브만 드로우하되 셰이더가 해당 큐브의 위치와 색상을 정확하게 읽어낼 수 있어야 합니다. 이를 위해 BRG 셰이더는 간단한 간접 참조를 추가로 사용합니다. 이 가시성 버퍼는 드로우 커맨드를 생성할 때 채우는 ‘int’의 배열일 뿐입니다. 

이 예시에서는 세 개의 int로 구성된 배열에 {0,3,7}을 채워 넣은 다음 세 인스턴스의 BRG 드로우 커맨드를 생성할 수 있습니다.

Figure 2: The BRG shader variant always uses the visibility indirection to fetch data from the persistent raw buffer. This small visibility indirection buffer can be generated for each frame according to your needs.
그림 2: BRG 셰이더 배리언트는 항상 가시성 간접 참조를 사용해 지속적으로 유지되는 원시 버퍼에서 데이터를 가져옵니다. 이 간단한 가시성 간접 참조 버퍼는 필요에 따라 각 프레임에 생성할 수 있습니다.

‘baseColor’ 프로퍼티를 가져오는 셰이더 코드는 다음과 같습니다.

if ( metadata_baseColor&(1<<31) )
{
    	// get the real index from the visibility buffer indirection
        	int visibleId = brg_visibility_array[GPU_instanceId];
        	uint base = (metadata_baseColor&0x7ffffffc);
        	uint offset = visibleId * sizeof(baseColor);
    	// fetch data from a custom array in BRG raw buffer
    	baseColor = brg_raw_buffer.Load( base + offset );
}
else
{
    	// fetch data from UnityPerMaterial (as usual)
        	baseColor = UnityPerMaterial.baseColor;
}
심화 설명: SRP 셰이더의 모든 프로퍼티(언릿, 심플릿, 릿)를 인스턴스화할 수 있으므로 모든 머티리얼 프로퍼티는 ‘if metadata&(1<<31’ 브랜치를 갖습니다. 인스턴스별 커스텀 평활도 값이 필요하지 않더라도 성능을 다소 저하시킬 수 있습니다. 이 샘플에서는 baseColor만 인스턴스화하려고 합니다. 이때 색상만 BRG 인스턴스화 대상으로 정의하는 셰이더 그래프를 만들 수 있습니다. 이렇게 생성된 코드는 색상 프로퍼티에 대해서만 데이터 가져오기용 간접 참조를 갖게 됩니다. 덕분에 저사양 GPU에서 셰이더가 근소하게나마 더 빨리 실행됩니다.

바닥 셀 렌더링하기

이 샘플 게임에서 바닥은 32x100개, 즉 3,200개의 셀로 구성됩니다. 각 셀은 위치, 높이, 색상이 정해져 있으며 카메라가 정지한 상태에서 셀이 스크롤됩니다. 하나의 행이 뷰 밖으로 스크롤되면 32개의 셀로 구성된 새로운 행을 삽입합니다.

A new row of cells is inserted when a full row has scrolled out of the view. Random height and color are used for new cells. You can have a look at BRG_Background.InjectNewSlice() in the sample.
새로운 셀의 행은 하나의 행이 뷰 밖으로 완전히 스크롤되었을 때 삽입됩니다. 새로운 셀의 높이와 색상은 무작위로 지정됩니다. 샘플에서 BRG_Background.InjectNewSlice()를 확인해 보세요.

뷰에는 항상 3,200개의 셀이 존재하므로 컬링은 필요하지 않습니다(즉, 모든 셀이 카메라 뷰 안에 항상 존재함). 각 셀의 위치를 지정하려면 셀별 obj2world 행렬, 조명의 역행렬, 색상이 필요합니다. 바닥 전체는 하나의 BRG 드로우 커맨드를 사용해서 렌더링합니다.

폭발 파편 렌더링하기

All debris have simple gravity physics and interact with floor cells. Everything is running on the CPU using Burst C# jobs
모든 파편은 간단한 중력 물리에 영향을 받으며 바닥 셀과 상호 작용합니다. 이는 버스트 C# 잡을 사용해 CPU에서 모두 실행됩니다.

샘플의 파편은 작은 큐브로 구성되며 각 큐브는 위치와 색상을 지니고 수직축을 기준으로 회전합니다. 바닥 셀과 매우 유사한 방식이죠. 이 작업을 위해 앞에서 BRG_Container.cs를 만들었습니다. 이 클래스는 바닥 셀이나 폭발 파편을 렌더링하는 BRG 오브젝트를 관리합니다. 모든 물리 애니메이션과 상호 작용은 BRG_Debris.cs를 사용하는 C# 코드로 이뤄집니다.

바닥 셀과 달리 파편의 개수는 프레임에 따라 다릅니다. 초기화 시에는 BRG_Container에 항목의 최대 개수를 지정합니다. 이 샘플에서 파편의 최대 개수는 16,384개(한 번의 폭발은 1,024개의 파편 큐브로 구성됨)이며 비동기 잡을 사용해서 중력장 내의 파편을 애니메이션화할 것입니다. 바닥 셀과 접촉한 파편은 지면을 파고 들어가는 방식으로 상호 작용합니다.

BRG 행렬 형식

BRG는 float4x4 대신 float3x4를 사용해 행렬을 저장하여 GPU 메모리 스토리지와 대역폭을 최적화합니다. 원시 버퍼의 BRG 행렬은 64바이트가 아니라 48바이트라는 점을 유념하세요.

BRG matrix is 48 bytes only (ie three float4) to improve GPU bandwidth
BRG 행렬은 GPU 대역폭을 개선하기 위해 48바이트로만 이뤄져 있습니다(즉, 세 개의 float4).

원시 버퍼는 다음과 같이 구성됩니다.

Figure 3: A 350 KiB SSBO raw buffer contains data for 3,200 instances, using the SoA layout.
그림 3: 350 KiB SSBO 원시 버퍼는 SoA 레이아웃을 사용해 3,200개 인스턴스에 대한 데이터를 포함합니다.

팁: 파편 원시 버퍼 데이터는 세 개의 커스텀 프로퍼티(obj2world, world2obj, color)를 사용하므로 바닥 데이터와 유사하게 보입니다. 파편의 항목 최대 개수는 16,384개이므로 원시 버퍼는 112x16,384바이트, 즉 1.75MiB가 됩니다. 특정 시점에 존재하는 파편 큐브의 개수에 따라 보통 모든 파편이 렌더링되지는 않습니다.

바닥 셀 애니메이션

주어진 GPU GraphicsBuffer는 358,400바이트입니다. 애니메이션은 CPU로 처리되므로 시스템 메모리에도 유사한 버퍼를 할당합니다(CPU는 시스템 메모리에서 최대 속도로 데이터를 처리할 수 있음). 이 두 번째 버퍼를 GPU 메모리의 ‘섀도우 카피’라고 부릅시다. C# 코드는 사인 함수를 사용해서 바닥 셀과 섀도우 카피의 파편을 애니메이션화합니다. 애니메이션이 완료되면 GraphicsBuffer.SetData API를 사용해서 섀도우 카피 버퍼를 GPU에 업로드합니다.

심화 설명: GPU 렌더링을 최적화하기 위해서는 데이터의 양을 최적화해야 하는 경우가 많습니다. 이 샘플은 표준 및 스톡 SRP 셰이더를 사용합니다. 그래서 행렬에 세 개의 float4를, 색상에 하나의 float4를 사용한 것입니다. 커스텀 셰이더를 작성하여 데이터 크기를 줄이거나 32비트의 바닥 셀 높이 값을 사용할 수도 있습니다.

더 나아가 셀 인덱스를 사용해서 월드 포지션을 계산한 다음 셰이더에서 행렬과 역행렬을 구하면 됩니다. 마지막으로 32비트 정수를 사용해 색상을 저장합니다. 최종적으로 각 항목에 112바이트가 아닌 8바이트를 업로드합니다. 이렇게 하면 GPU 데이터 업로드 속도가 14배 향상됩니다. 이는 셰이더 가져오기 코드를 다시 작성해야 함을 의미합니다.

BRG BatchID

모든 BRG 드로우 커맨드에는 MeshID, MaterialID, BatchID가 필요합니다. 앞의 두 가지는 쉽게 이해할 수 있지만 BatchID는 다소 복잡합니다. BatchID는 ‘배치의 한 종류’라고 간주할 수 있습니다. 바닥을 렌더링하기 위해서는 아래와 같이 정의된 배치의 한 종류를 등록해야 합니다.

  1. ‘unity_ObjectToWorld’ 프로퍼티는 BRG 원시 버퍼의 오프셋 0에서 시작하는 배열
  2. ‘unity_WorldToObject’ 프로퍼티는 오프셋 153,600에서 시작하는 배열
  3. ‘_BaseColor’ 프로퍼티는 오프셋 307,200에서 시작하는 배열

생성 시 이러한 배치 종류를 등록하는 코드는 다음과 유사합니다.

    	int objectToWorldID = Shader.PropertyToID("unity_ObjectToWorld");
    	int worldToObjectID = Shader.PropertyToID("unity_WorldToObject");
    	int colorID = Shader.PropertyToID("_BaseColor");
    	var batchMetadata = new NativeArray<MetadataValue>(3, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
 
                    	batchMetadata[0] = CreateMetadataValue(objectToWorldID, 0, true);   	// matrices
                    	batchMetadata[1] = CreateMetadataValue(worldToObjectID, 3200*3*16, true); // inverse matrices
                    	batchMetadata[2] = CreateMetadataValue(colorID, 3200*3*16*2, true); // colors
                    	m_batchId = m_BatchRendererGroup.AddBatch(batchMetadata, m_GPUPersistentRawBuffer.bufferHandle, 0, 0);

생성할 때 m_batchId가 생기므로 각 BRG 드로우 커맨드에 사용할 수 있습니다(이를 통해 셰이더가 해당 종류의 배치에서 데이터를 올바르게 가져올 수 있음).

팁: BatchRendererGroup.AddBatch는 렌더링 커맨드가 아닙니다. 이는 향후 렌더링 커맨드를 위해 배치의 한 종류를 등록하는 데 사용됩니다.

주의해야 할 디테일: GLES 예외

지금까지 바닥 셀을 애니메이션화하고, 섀도우 카피 시스템 메모리 버퍼를 GPU에 업로드하고, 인스턴스 3,200개의 단일 드로우 커맨드를 사용해서 모든 셀을 렌더링했습니다.

이는 DirectX, Vulkan, Metal, 다양한 게임 콘솔을 포함한 대부분의 플랫폼에서 작동하지만 GLES에서는 작동하지 않습니다. 문제는 대부분의 GLES 3.0 기기가 버텍스 단계에서 SSBO에 액세스하지 못한다는 것입니다(즉, GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS 값이 0임). 그래서 그래픽스 API가 GLES로 설정된 경우, BRG는 상수 버퍼 또는 UBO를 대신 사용해서 원시 데이터를 저장합니다.

이때 제한 조건이 발생합니다. 상수 버퍼의 크기는 무제한이지만 셰이더가 실행 중일 때는 상수 버퍼의 일부(창)만 볼 수 있게 되는 것입니다. 창의 크기는 하드웨어와 드라이버에 따라 결정되지만 일반적으로는 16KiB입니다.

팁: UBO 모드에서는 항상 BatchRendererGroup.GetConstantBufferMaxWindowSize() API를 사용해서 적합한 BRG 창 크기를 적용해야 합니다.

GLES에서 실행할 경우 코드가 어떻게 변경되는지 알아봅시다. 바닥 셀에 대한 데이터는 총 350KiB입니다. 셰이더가 350KiB를 한 번에 처리할 수 없으므로 하나의 DrawInstanced(3,200)를 실행할 수는 없습니다. 따라서 UBO 내의 데이터를 16KiB 블록에 맞게 분할하여 드로우당 인스턴스의 개수를 극대화해야 합니다. 하나의 바닥 셀은 112바이트이므로(두 개의 행렬과 하나의 색상) 16,384를 112로 나눈 값인 146개의 인스턴스를 16KiB 블록 하나에 넣을 수 있습니다. 3,200개의 인스턴스를 렌더링하기 위해서는 21개의 DrawInstanced(146)와 마지막 DrawInstanced(134)를 발행해야 합니다.

이제 350KiB UBO는 아래와 같이 각각 16KiB인 창 블록 22개로 분할됩니다.

Figure 4: In GLES, the raw buffer is UBO (not SSBO). Data for 3,200 instances is split into 22 windows. Each DrawInstanced(146) will fetch data from a 16 KiB region. Note that the last window contains 134 instances only, which is why there’s a small gap between the last yellow, green, and blue region.
그림 4: GLES에서 원시 버퍼는 UBO입니다(SSBO가 아님). 3,200개 인스턴스에 대한 데이터는 22개의 창으로 분할됩니다. 각 DrawInstanced(146)는 16KiB 영역에서 데이터를 가져옵니다. 마지막 창은 134개의 인스턴스만 가지므로 마지막 노란색, 초록색, 파란색 영역 간에 작은 틈이 존재하게 됩니다.

팁: UBO 모드에서 각 창의 오프셋은 BatchRendererGroup.GetConstantBufferOffsetAlignment()에 정렬되어야 합니다. 일반적인 정렬값의 범위는 4~256바이트입니다.

GLES는 UBO와 16KiB 창으로 인해 22개의 BatchID를 등록해서 각 창의 오프셋을 저장해야 합니다. 이후 초기화 코드에는 루프가 필요합니다.

	 // Register one BatchID per 16KiB window, using the right offsets
    	m_batchIDs = new BatchID[m_windowCount];
    	for (int b = 0; b < m_windowCount; b++)
    	{
        	batchMetadata[0] = CreateMetadataValue(objectToWorldID, 0, true);   	// matrices
        	batchMetadata[1] = CreateMetadataValue(worldToObjectID, m_maxInstancePerWindow * 3 * 16, true); // inverse matrices
        	batchMetadata[2] = CreateMetadataValue(colorID, m_maxInstancePerWindow * 3 * 2 * 16, true); // colors
        	int offset = b * m_alignedGPUWindowSize;
        	m_batchIDs[b] = m_BatchRendererGroup.AddBatch(batchMetadata, m_GPUPersistentInstanceData.bufferHandle, (uint)offset,(uint)m_alignedGPUWindowSize);
    	}

팁: 이 게임 샘플에서는 GLES(UBO)와 기타 그래픽스 API(SSBO)를 지원하기 위해 BRG_Container.cs가 초기화 시점에 몇 가지 변수를 설정합니다. SSBO 모드에서 m_windowCount는 1로, m_alignedGPUWindowSize는 전체 버퍼의 크기로 설정됩니다. UBO 모드에서 m_alignedGPUWindowSize는 16KiB로, m_windowCount는 16KiB 블록의 개수로 설정됩니다. (16KiB는 일반적으로 사용되는 값입니다. GetConstantBufferMaxWindowSize() API를 사용해서 정확한 값을 확인하세요.)

데이터 업로드하기

CPU가 모든 행렬과 색상을 시스템 메모리에 업데이트하고 나면 데이터를 GPU에 업로드할 수 있습니다. 이 과정은 BRG_Container.UploadGpuData 함수를 통해 수행됩니다. SoA 데이터 모델 때문에 하나의 메모리 블록을 업로드할 수는 없습니다. 파편에 대한 버퍼는 16,384개의 항목을 저장할 수 있습니다. GLES 모드에서 화면에 16,384개의 파편이 존재할 경우 각각 16KiB인 창이 113개가 됩니다.

하지만 특정 프레임에 5,300개의 파편 큐브만 존재한다면 어떻게 될까요? 이 경우 각 창에 146개의 항목이 존재하므로 초반 36개의 16KiB 창을 업로드해서 단일 SetData(36x16KiB)를 사용할 수 있어야 합니다. 마지막 창에는 44개의 파편 큐브만 표시되어야 합니다. 44개의 행렬을 업로드하기 위해 행렬과 색상을 반전시키고 세 개의 SetData 커맨드를 사용합니다. 최종적으로 네 개의 SetData 커맨드가 발행되어야 합니다.

Up to four GfxBuffer.SetData commands are needed to upload N items.
N개의 항목을 업로드하려면 최대 네 개의 GfxBuffer.SetData 커맨드가 필요합니다.

팁: SSBO 모드에서도 항목의 개수가 최댓값보다 적을 때는(예컨대 최댓값이 16,384인데 파편이 5,300개인 경우) 세 개의 SetData 커맨드가 필요합니다. 상세한 구현 방법은 BRG_Container.UploadGpuData(int instanceCount)를 통해 확인하세요.

주요 BRG 사용자 콜백

BRG의 주요 진입점은 생성 당시에 제공한 컬링 콜백 함수입니다. 프로토타입은 다음과 같습니다.

public JobHandle OnPerformCulling(BatchRendererGroup rendererGroup, BatchCullingContext cullingContext, BatchCullingOutput cullingOutput, IntPtr userContext)

이 콜백의 코드는 두 가지를 담당합니다.

  1. 모든 드로우 커맨드를 출력 BatchCullingOut 구조체에 생성하기
  2. 자체적인 컬링 코드 내의 BatchCullingContext 읽기 전용 구조체에 제공된 정보를 사용하거나 사용하지 않기

참고: 이러한 연산을 수행하기 위해 비동기 잡을 시작하려는 경우 콜백이 JobHandle을 반환합니다. 엔진은 이를 사용하여 결과가 필요한 시점에 동기화하므로 커맨드 생성 코드가 메인 스레드를 차단하지 않습니다.

BatchCullingContext는 카메라 행렬, 카메라 절두체 계획 등의 정보를 포함합니다. 사실상 더 적은 수의 드로우 커맨드를 컬링하고 생성하는 데 필요한 모든 데이터입니다. 이 샘플에서 모든 오브젝트는 카메라 뷰 안에 존재하므로(바닥 셀과 파편) 컬링 코드를 사용할 필요가 없습니다.

BatchCullingOutputDrawCommands 구조체는 배열을 비롯해 다양한 데이터를 포함합니다. 해당 배열에 네이티브 메모리를 할당하는 것은 사용자의 몫입니다. 엔진은 데이터가 소모된 이후 그 메모리를 해제하는 과정을 담당합니다(사용자는 할당을, Unity는 해제를 담당함). 메모리 할당은 Allocator.TempJob 유형이어야 합니다.

	private static T* Malloc<T>(uint count) where T : unmanaged
	{
    	return (T*)UnsafeUtility.Malloc(
        	UnsafeUtility.SizeOf<T>() * count,
        	UnsafeUtility.AlignOf<T>(),
        	Allocator.TempJob);
	}

첫 번째로 할당해야 할 배열은 가시성 int 배열입니다. 이 샘플에서는 모든 요소가 보인다고 가정하므로 가시성 int 배열을 {0,1,2,3,4,...} 같이 점진적으로 증가하는 값으로 구성하면 됩니다.

드로우 커맨드 생성

BRG 드로우 커맨드는 거의 GPU DrawInstanced 호출에 가깝습니다. 할당하고 채워야 할 가장 중요한 배열은 BatchDrawCommand입니다. 예를 들어 현재 프레임에 4,737개의 파편 큐브가 존재한다고 가정하겠습니다.

GLES 모드에서 m_maxInstancePerWindow는 146입니다. 다음과 같이 m_maxInstancePerWindow로 나눈 m_instanceCount의 상한값을 사용해 드로우 커맨드의 개수를 계산하고 버퍼를 할당할 수 있습니다.

int drawCommandCount = (m_instanceCount + m_maxInstancePerWindow - 1) / m_maxInstancePerWindow;
drawCommands.drawCommands = Malloc<BatchDrawCommand>((uint)drawCommandCount);

유사한 파라미터가 여러 드로우 커맨드에 중복되지 않도록 BatchCullingOutputDrawCommands는 BatchDrawRange 구조체의 배열을 갖습니다. BatchDrawRange.filterSettings 내에 renderingLayerMask, receive shadow flags 같은 다양한 파라미터를 설정할 수 있습니다. 모든 드로우 커맨드가 동일한 렌더링 설정을 공유할 것이므로, 드로우 커맨드 0부터 적용되며 모든 drawCommandCount 커맨드를 포함하는 하나의 DrawCommandRange 구조체를 할당할 수 있습니다.

drawCommands.drawRanges[0] = new BatchDrawRange
{
	drawCommandsBegin = 0,
	drawCommandsCount = (uint)drawCommandCount,
	filterSettings = new BatchFilterSettings
	{
    	renderingLayerMask = 1,
    	layer = 0,
    	motionMode = MotionVectorGenerationMode.Camera,
    	shadowCastingMode = m_castShadows ? ShadowCastingMode.On : ShadowCastingMode.Off,
    	receiveShadows = true,
    	staticShadowCaster = false,
    	allDepthSorted = false
	}
};

이어서 드로우 커맨드를 입력합니다. 각 BatchDrawCommand는 meshID, batchID(메타데이터 사용법 파악용), materialID를 포함합니다. 가시성 int 배열 버퍼의 시작 오프셋도 포함합니다. 이 샘플에서는 절두체 컬링이 필요하지 않으므로 가시성 배열을 {0,1,2,3,...}으로 채우면 됩니다. 그러면 모든 드로우 커맨드가 {0,1,2,3,..} 간접 참조를 참조하므로 각 BatchDrawCommand는 0을 가시성 배열의 시작 오프셋으로 사용합니다. 아래의 코드는 필요한 모든 드로우 커맨드를 할당하고 입력합니다.

drawCommands.drawCommands = Malloc<BatchDrawCommand>((uint)drawCommandCount);
int left = m_instanceCount;
for (int b = 0; b < drawCommandCount; b++)
{
	int inBatchCount = left > maxInstancePerDrawCommand ? maxInstancePerDrawCommand : left;
	drawCommands.drawCommands[b] = new BatchDrawCommand
	{
    	visibleOffset = (uint)0,	// all draw command is using the same {0,1,2,3...} visibility int array
    	visibleCount = (uint)inBatchCount,
    	batchID = m_batchIDs[b],
    	materialID = m_materialID,
    	meshID = m_meshID,
    	submeshIndex = 0,
    	splitVisibilityMask = 0xff,
    	flags = BatchDrawCommandFlags.None,
    	sortingPosition = 0
	};
	left -= inBatchCount;
}

마치며: 포럼에서 더욱 심층적인 내용을 살펴보세요

BatchRendererGroup을 직접 구동하는 과정은 약간 번거롭지만 커스텀 셰이더나 추가적인 패키지 없이도 바로 작동합니다. 인스턴스화된 커스텀 프로퍼티를 지닌 수많은 CPU 시뮬레이션 오브젝트를 렌더링해야 하는 상황에서는 BatchRendererGroup이 가장 적합합니다.

본 프로젝트는 GitHub 저장소에서 다운로드할 수 있습니다.

C# 잡 시스템과 버스트 컴파일러를 활용해 저사양 CPU에서도 모든 애니메이션과 상호 작용을 최대 속도로 처리하는 방법을 포럼에서 자세히 알아보세요.

2023년 10월 3일 엔진 & 플랫폼 | 15 분 소요

Is this article helpful for you?

Thank you for your feedback!

관련 게시물