TECHARTNOMAD | MAZELINE.TECH

UNREAL ENGINE

UE5 GPU 프러스텀 컬링 동작

jplee 2026. 9. 29. 00:54

앞선 글(GPU 컬링을 쓰는데 왜 렌더 스레드가 느려질까)에서는 GPU 컬링 시대에도 CPU가 매 프레임 수집해야 하는 프리미티브 정보의 비용을 봤다. 이번 글에서는 반대편을 살펴본다. GPU 안에서 프러스텀 컬링은 정확히 어떻게 실행되는가.

UE5의 일반 메시 파이프라인에서 GPU 프러스텀 컬링은 컴퓨트 셰이더 기반의 "GPU 인스턴스 컬링(instance culling)"으로 구현되어 있다. 흔히 "GPU 드리븐 렌더링"이라고 부르는 구조이며, Nanite와 컬링 코드를 공유한다. 이 글에서는 UE 5.8.3 소스를 따라 파이프라인의 시작부터 끝까지 살펴본다.

  1. 컴퓨트 셰이더의 스레드가 어떤 인스턴스를 어떻게 찾아내고(로드밸런서)
  2. 프러스텀 테스트의 실제 수학이 어떻게 되어 있으며
  3. 결과가 원자적 연산과 indirect draw args로 어떻게 기록되고
  4. CPU는 인스턴스 수를 모른 채 어떻게 드로우를 내는지

코드 블록과 함께 정리한다. 인용 라인 번호는 5.8.3 기준.


1. 전체 그림: 데이터는 이렇게 흐른다

한 프레임에서 GPU 인스턴스 컬링 파이프라인의 데이터 흐름은 다음과 같다.

[CPU] 드로우 커맨드 확정 시점 (InitViews/메시 패스 셋업)
  └─ AddInstancesToDrawCommand(): 드로우 커맨드마다 "인스턴스 범위" 등록
      └─ GPU 로드밸런서: Item(인스턴스 스팬) → Batch(64스레드 그룹)로 패킹
          └─ 버퍼 업로드 (Batches, Items, PayloadData, DrawCommandDescs, ViewIds ...)

[GPU] RDG 패스 "BuildRenderingCommands(Culling=On)"
  1. ClearIndirectArgInstanceCount — 모든 드로우 커맨드의 인스턴스 카운트를 0으로
  2. CullInstances(UnCulled)   — 싱글 인스턴스(일반 프리미티브): 컬링 없이 통과
  3. CullInstances(Generic)    — 다중 인스턴스(ISM/HISM): 뷰마다 컬링 수행 ★
  4. (순서 보존 시) Instance Compaction Phase 1/2 — 살아남은 인스턴스 재배치

[GPU] 메시 패스 드로우
  └─ DrawIndexedPrimitiveIndirect — 인스턴스 수는 GPU가 쓴 카운트 그대로

핵심 설계는 간단하다. CPU는 검사할 인스턴스 범위만 지정하고, 살아남은 인스턴스의 수는 GPU가 계산한다. 살아남은 인스턴스의 ID 목록(InstanceIdsBuffer)과 인스턴스 카운트(indirect args)는 모두 GPU 메모리에서 생성되며, 드로우는 indirect call로 실행된다.

이제 각 단계의 코드를 보자.


2. 커널의 뼈대 — 스레드 1개 = 인스턴스 1개

Engine/Shaders/Private/InstanceCulling/BuildInstanceDrawCommands.usf — InstanceCullBuildInstanceIdBufferCS (약 242줄)

[numthreads(NUM_THREADS_PER_GROUP, 1, 1)]  // NUM_THREADS_PER_GROUP = 64
void InstanceCullBuildInstanceIdBufferCS(uint3 GroupId : SV_GroupID, int GroupThreadIndex : SV_GroupIndex)
{
	uint DispatchGroupId = GetUnWrappedDispatchGroupId(GroupId);

	if (DispatchGroupId >= InstanceCullingLoadBalancer_GetNumBatches())
	{
		return;  // 이 그룹에 배정된 배치가 없음
	}

	// ... BatchInfo 로드 후, 로드밸런서가 "나는 어떤 아이템(드로우 커맨드의 인스턴스 스팬)의
	//     몇 번째 인스턴스를 담당하는지" 계산해 줌 (아래 ⑤에서 상세)
	FInstanceCullingSetup InstanceCullingSetup = LoadInstanceCullingSetup(GroupId, GroupThreadIndex, ...);
	FInstanceWorkSetup WorkSetup = InstanceCullingSetup.InstanceWorkSetup;

	if (!WorkSetup.bValid)
	{
		return;  // 이 스레드가 맡은 인스턴스가 없음 (배치 끝의 여분 스레드)
	}

	uint InstanceId = InstanceCullingSetup.InstanceId;
	const FInstanceCullingPayload Payload = LoadInstanceCullingPayload(WorkSetup.Item.Payload, BatchInfo);
	const FDrawCommandDesc DrawCommandDesc = UnpackDrawCommandDesc(DrawCommandDescs[Payload.IndirectArgIndex]);

	// GPU Scene에서 이 인스턴스와 소속 프리미티브의 데이터를 직접 읽는다
	const FInstanceSceneData InstanceData = GetInstanceSceneData(InstanceId);
	const FPrimitiveSceneData PrimitiveData = GetPrimitiveData(InstanceData.PrimitiveId);

	for (uint ViewIdIndex = 0; ViewIdIndex < BatchInfo.NumViewIds; ++ViewIdIndex)
	{
		// ★ 컬링 판정 (다음 절)
		uint CullingFlags = 0;
		bool bVisible = IsInstanceVisible(PrimitiveData, InstanceData, InstanceId, BatchInfo.ViewIdsOffset + ViewIdIndex,
		                                  BatchInfo.bAllowOcclusionCulling, BatchInfo.bDrawOnlySelected, DrawCommandDesc, CullingFlags);

		if (bVisible)
		{
			// ★ 살아남으면 드로우 커맨드의 인스턴스 카운트를 원자적으로 1 증가시키고
			//   돌려받은 오프셋에 인스턴스 ID를 기록한다
			uint OutputOffset;
			InterlockedAdd(DrawIndirectArgsBufferOut[Payload.IndirectArgIndex * INDIRECT_ARGS_NUM_WORDS + 1], 1U, OutputOffset);
			WriteInstance(InstanceDataOutputOffset + OutputOffset * INSTANCE_DATA_STRIDE_ELEMENTS,
			              InstanceId, PrimitiveData, InstanceData, ViewIdIndex, CullingFlags, DrawCommandDesc.MeshLODIndex);
		}
	}
}
  • 커널의 구조는 놀랄 만큼 단순하다. 스레드 하나가 인스턴스 하나를 담당하고, 등록된 뷰(메인 뷰, 섀도, VSM 페이지 등 ViewIds에 들어간 모든 컬링 뷰)를 순회하며 가시성을 판정한다. 멀티뷰 상황에서 같은 인스턴스가 여러 뷰에 보이면 인스턴스 ID가 뷰 수만큼 기록된다(스테레오 모드는 눈 2개를 한 번에 처리하는 특수 경로).
  • 결과 기록 방식이 이 파이프라인의 핵심 트릭이다. InterlockedAdd(DrawIndirectArgsBufferOut[... + 1], 1, OutputOffset) — indirect args의 두 번째 워드가 InstanceCount(FRHIDrawIndexedIndirectParameters 레이아웃)이고, 살아남은 스레드들이 원자적으로 카운트를 올리면서 동시에 "내 ID를 어디에 쓸지" 오프셋을 받아온다. 즉 카운트 증가와 산출물 분배가 원자적 연산 하나로 해결된다.
  • GetInstanceSceneData(InstanceId) — 인스턴스의 트랜스폼·바운드는 GPU Scene 버퍼에서 직접 읽는다. CPU가 이 정보를 넘겨주지 않는다. 이것이 "CPU는 인스턴스 수조차 모르는" 구조의 기반이다.
  • DrawCommandDescs에는 머티리얼의 WPO 사용 여부, LOD 인덱스, Min/MaxScreenSize가 비트 패킹되어 있다. 컬링 판정에 필요한 "드로우 커맨드 단위" 정보만 CPU가 미리 넘겨주는 것이다.

3. 가시성 판정 순서 — 싼 테스트부터 비싼 테스트로

같은 파일 — IsInstanceVisible() (약 117줄)

bool IsInstanceVisible(FPrimitiveSceneData PrimitiveData, FInstanceSceneData InstanceData, uint InstanceId,
                       uint ViewIdIndex, bool bAllowOcclusionCulling, bool bDrawOnlySelected,
                       FDrawCommandDesc DrawCommandDesc, inout uint CullingFlags)
{
	CullingFlags = INSTANCE_CULLING_FLAGS_DEFAULT;

#if CULL_INSTANCES
	// 죽은 인스턴스(삭제됨)는 즉시 탈락
	if (!InstanceData.ValidInstance)
	{
		return false;
	}
	// (에디터) 선택된 인스턴스만 그리기 ... (중략)

	// 바운드가 없는 특수 케이스(라인배처 등)는 통과 — 컬링 불가
	if (dot(InstanceData.LocalBoundsExtent, InstanceData.LocalBoundsExtent) <= 0.0f)
	{
		return true;
	}
#endif

	uint ViewDataIndex = ViewIds[ViewIdIndex];

	if (ViewDataIndex < NumCullingViews)
	{
		FNaniteView NaniteView = GetNaniteView(ViewDataIndex);
		FInstanceDynamicData DynamicData = CalculateInstanceDynamicData(NaniteView, InstanceData, true);

		FBoxCull Cull;
		Cull.Init(NaniteView, LocalBoundsCenter, LocalBoundsExtent, InstanceData.NonUniformScale,
		          DynamicData.LocalToTranslatedWorld, DynamicData.PrevLocalToTranslatedWorld);

		Cull.Distance(PrimitiveData);            // 1) 거리/드로우 디스턴스
		if (!Cull.bEnableWPO) { CullingFlags &= ~INSTANCE_CULLING_FLAG_EVALUATE_WPO; }

#if CULL_INSTANCES
		Cull.ScreenSize(DrawCommandDesc.MinScreenSize, DrawCommandDesc.MaxScreenSize);  // 2) 화면 크기
		Cull.GlobalClipPlane();                                                        // 3) 글로벌 클립 플레인(1인칭)

		BRANCH
		if (Cull.bIsVisible)
		{
			Cull.Frustum();                                                             // 4) 프러스텀 ★
		}

#if OCCLUSION_CULL_INSTANCES
		BRANCH
		if (Cull.bIsVisible && bAllowOcclusionCulling)
		{
			// 5) (실험적) 이전 프레임 HZB로 오클루전 컬링
			FFrustumCullData PrevCull = BoxCullFrustum(... NaniteView.PrevTranslatedWorldToClip ...);
			if (PrevCull.bIsVisible && !PrevCull.bCrossesNearPlane)
			{
				FScreenRect PrevRect = GetScreenRect(NaniteView.HZBTestViewRect, PrevCull, 4);
				PrevRect.Depth = RoundUpF16(PrevRect.Depth);
				Cull.bIsVisible = IsVisibleHZB(PrevRect, true);
			}
		}
#endif
#endif
		return Cull.bIsVisible;
	}
	return true;
}
  • 판정 순서가 비용 순서다. 거리 컬 → 화면 크기 컬 → 글로벌 클립 플레인 → 프러스텀 → (선택) HZB 오클루전. 각 단계에서 탈락하면 다음 단계의 비용을 물지 않는다. BRANCH로 감싼 것은 실제 분기 예측 힌트이기도 하다.
  • CPU 프러스텀 컬(전편)에서 이미 1차 컬링을 통과한 프리미티브의 인스턴스를 GPU에서 다시 컬링한다. 왜 중복인가? CPU 컬은 프리미티브 바운드 단위지만, ISM 컴포넌트 하나가 수천 인스턴스를 품고 있어서다. 컴포넌트 바운드가 뷰에 걸치면 모든 인스턴스가 1차 통과하지만, 실제로 프러스텀 안에 있는 인스턴스는 극히 일부일 수 있다. GPU 컬링은 이 인스턴스 단위 정밀 컬링을 담당한다.
  • CullingFlags의 INSTANCE_CULLING_FLAG_EVALUATE_WPO에 주목하자. 컬링 결과가 "보인다/안 보인다"만 결정하지 않는다. World Position Offset을 평가할지도 결정해서 버텍스 셰이더에 전달한다. WPO가 큰 머티리얼(바람에 흔들리는 나무 등)은 컬링 바운드를 보수적으로 키워야 하고, 멀리서는 WPO를 꺼서 정적 바운드로 컬링할 수 있게 하는 것이다.
  • GetNaniteView(ViewDataIndex) — 컬링 뷰 데이터 구조가 FNaniteView다. 일반 메시 파이프라인의 인스턴스 컬링이 Nanite와 컬링 코드를 공유한다는 뜻이며, 메인 뷰뿐 아니라 가상 섀도 맵(VSM)의 페이지 뷰·섀도 깊이 뷰도 같은 컬링 인프라를 쓴다.

4. 프러스텀 테스트의 실제 수학 — 클립 공간 8코너 투영

Engine/Shaders/Private/Nanite/NaniteHZBCull.ush — BoxCullFrustumPerspective() (약 444줄)

원근 투영 경로가 기본 경로다. 박스(인스턴스 로컬 바운드)의 8개 코너를 클립 공간으로 변환하고, SIMD 친화적인 min/max로 모든 판정을 한 번에 계산한다.

FFrustumCullData BoxCullFrustumPerspective(float3 Center, float3 Extent, float4x4 LocalToWorld,
                                           float4x4 WorldToClip, float4x4 ViewToClip, bool bSkipFrustumCull)
{
    FFrustumCullData Cull;

    float4  DX = (2.0f * Extent.x) * mul(LocalToWorld[0], WorldToClip);
    float4  DY = (2.0f * Extent.y) * mul(LocalToWorld[1], WorldToClip);

    float   MinW = +INFINITE_FLOAT;
    float   MaxW = -INFINITE_FLOAT;
    float4  PlanesMin = 1.0f;                 // (x-w, y-w, -x-w, -y-w)의 최솟값 → 좌/하/우/상 평면 테스트

    Cull.RectMin = float3(+1, +1, +1);        // 클립 공간 스크린 사각형 (-1~1)
    Cull.RectMax = float3(-1, -1, -1);

    // To discourage the compiler from overlapping the entire calculation, which uses an excessive
    // number of VGPRs, the evaluation is split into 4 isolated passes with two corners per pass.
    #define EVAL_POINTS(PC0, PC1) \
        MinW            = min3(MinW, PC0.w, PC1.w); \
        MaxW            = max3(MaxW, PC0.w, PC1.w); \
        PlanesMin       = min3(PlanesMin, float4(PC0.xy, -PC0.xy) - PC0.w, float4(PC1.xy, -PC1.xy) - PC1.w); \
        float2 PS0      = PC0.xy / PC0.w; \
        float2 PS1      = PC1.xy / PC1.w; \
        Cull.RectMin.xy = min3(Cull.RectMin.xy, PS0, PS1); \
        Cull.RectMax.xy = max3(Cull.RectMax.xy, PS0, PS1);

    // 8개 코너를 4개의 고립된 패스(패스당 2코너)로 평가 — VGPR 압박 완화
    float4 PC000, PC100;
    PLATFORM_SPECIFIC_ISOLATE
    {
        float4 DZ = (2.0f * Extent.z) * mul(LocalToWorld[2], WorldToClip);
        PC000 = mul(mul(float4(Center - Extent, 1.0), LocalToWorld), WorldToClip);
        PC100 = PC000 + DZ;
        EVAL_POINTS(PC000, PC100);
    }
    // ... 나머지 3개 패스: PC001/PC101(+DX), PC011/PC111(+DY), PC010/PC110(-DX)

    float MinZ = MaxW * ViewToClip[2][2] + ViewToClip[3][2];
    float MaxZ = MinW * ViewToClip[2][2] + ViewToClip[3][2];

    // Near is z=1 (리버스 Z)
    bool bInFrontNearPlane = MinW <= MaxZ;
    bool bBehindNearPlane  = MaxW >  MinZ;
    // Far is z=0
    bool bInFrontFarPlane = 0 <  MaxZ;
    bool bBehindFarPlane  = 0 >= MinZ;

    Cull.bCrossesNearPlane = bInFrontNearPlane;
    Cull.bCrossesFarPlane  = bBehindFarPlane;
    Cull.bIsVisible        = bBehindNearPlane && bInFrontFarPlane;

    // w가 0을 가로지르면(카메라가 박스 안) 화면 전체로 취급 — 나눗셈 폭주 방지
    if (MinW <= 0.0f && MaxW > 0.0f)
    {
        Cull.RectMin = float3(-1, -1, -1);
        Cull.RectMax = float3(+1, +1, +1);
    }
    else
    {
        Cull.RectMin.z = MinZ / MaxW;
        Cull.RectMax.z = MaxZ / MinW;
    }

    if (!bSkipFrustumCull)
    {
        // 4개 사이드 평면: 코너 전체가 한 평면 바깥이면 탈락
        const bool bFrustumCull = any(PlanesMin > 0.0f);
        Cull.bIsVisible = Cull.bIsVisible && !bFrustumCull;
    }
    return Cull;
}
  • 왜 6평면 점검이 아니라 8코너 투영인가? 두 가지 이유가 코드에 그대로 보인다. 첫째, min3/max3 연속으로 코너 8개를 한 번에 접어 SIMD 레인을 꽉 채울 수 있다. 둘째, 같은 루프에서 **클립 공간 스크린 사각형(RectMin/RectMax)**이 공짜로 나온다. 이 사각형은 프러스텀 컬링만으로 끝나지 않고, HZB 오클루전 컬링(GetScreenRect)과 VSM 페이지 매핑에서 그대로 재사용된다. 하나의 변환으로 여러 컬링 판정을 동시에 준비하는 설계다.
  • 사이드 평면 테스트 PlanesMin > 0의 의미: float4(x, y, -x, -y) - w는 좌(x≤w)/하/우/상 평면의 부호화된 거리다. 8코너의 최솟값이 어떤 평면에서도 0보다 크다 = 박스 전체가 그 평면 바깥 = 확정 탈락. 평면 방정식을 6번 도는 것보다 훨씬 GPU 친화적이다.
  • 근/원평면 판정은 w공간에서, 나눗셈 없이 이루어진다(MinZ/MaxW 계산은 사각형 확정 후에만). w≈0 근처의 정확도 문제를 피하는 것이고, MinW <= 0 && MaxW > 0(카메라가 바운드 내부)인 경우는 아예 사각형을 전체 화면으로 클램프해서 나눗셈 폭주를 막는다.
  • PLATFORM_SPECIFIC_ISOLATE로 8코너 평가를 4패스로 쪼갠 주석이 이 파이프라인의 성격을 보여준다 — 컴파일러가 전체를 합치면 VGPR(레지스터) 사용량이 폭증해 점유율이 떨어지므로, 일부러 명령어 순서를 끊어 하드웨어가 스레드를 더 많이 싣게 만든다. 컬링 셰이더 하나에 콘솔 최적화 관점이 박혀 있다.
  • 직교 투영(BoxCullFrustumOrtho)은 더 단순하다 — w 나눗셈이 없으므로 클립 공간 AABB의 min/max만으로 판정. 최종 디스패처 BoxCullFrustum()이 bIsOrtho || !bNearClip으로 갈라준다.

5. GPU 로드밸런서 — 가변 길이 워크를 64스레드 그룹에 채우기 (CPU 편)

Engine/Source/Runtime/Renderer/Private/InstanceCulling/InstanceCullingLoadBalancer.h — TInstanceCullingLoadBalancer::Add() (약 134줄)

문제는 드로우 커맨드마다 인스턴스 수가 다르다는 점이다. 어떤 커맨드는 3개, 어떤 커맨드는 10만 개를 처리한다. 반면 컴퓨트 셰이더의 스레드 그룹 크기는 64개로 고정되어 있다. 이 가변 길이 작업을 어떻게 배분해야 스레드 낭비를 줄일 수 있을까?

class FInstanceCullingLoadBalancerBase
{
public:
	static constexpr uint32 ThreadGroupSize = 64U;
	static constexpr uint32 PrefixBits = 6U;                        // log2(64)
	static constexpr uint32 NumInstancesItemBits = PrefixBits + 1U; // 한 아이템이 그룹 전체(64)를 쓸 수도 있으니 +1비트
	// ...
};

// CPU에서 워크 아이템 추가: (InstanceDataOffset, NumInstanceDataEntries, Payload)
void Add(uint32 InstanceDataOffset, uint32 NumInstanceDataEntries, uint32 Payload)
{
	uint32 InstancesAdded = 0;
	while (InstancesAdded < NumInstanceDataEntries)
	{
		uint32 MaxInstancesThisBatch = ThreadGroupSize - CurrentBatchPrefixSum;

		if (MaxInstancesThisBatch > 0)
		{
			// 현재 배치의 남은 슬롯에 맞춰 잘라 넣는다
			const uint32 NumInstancesThisItem = FMath::Min(MaxInstancesThisBatch, NumInstanceDataEntries - InstancesAdded);

			Data->Items.Add(PackItem(InstanceDataOffset + InstancesAdded, NumInstancesThisItem, Payload, CurrentBatchPrefixSum));
			CurrentBatchNumItems += 1U;
			InstancesAdded += NumInstancesThisItem;
			CurrentBatchPrefixSum += NumInstancesThisItem;
		}

		// 배치(=스레드 그룹)가 꽉 차면 flush하고 다음 배치 시작
		if (MaxInstancesThisBatch <= 0U || CurrentBatchPrefixSum >= ThreadGroupSize)
		{
			Data->Batches.Add(PackBatch(CurrentBatchFirstItem, CurrentBatchNumItems));
			CurrentBatchFirstItem = uint32(Data->Items.Num());
			CurrentBatchPrefixSum = 0u;
			CurrentBatchNumItems = 0U;
		}
	}
	TotalInstances += InstancesAdded;
}
  • CPU는 인스턴스 스팬을 **64개 단위 배치(스레드 그룹)**에 빈틈없이 채운다. 3개짜리 아이템과 61개짜리 아이템이 한 그룹에 들어갈 수 있고, 10만 개짜리 아이템은 여러 배치에 걸쳐 잘린다. 각 아이템은 PackItem(오프셋, 개수, 페이로드, 배치 내 프리픽스 오프셋)으로 단 두 uint에 비트 패킹된다(인스턴스 개수 7비트, 프리픽스 오프셋 6비트).
  • 여기서 전편의 CPU 피드(AddInstancesToDrawCommand)와 만난다. CPU가 한 일은 결국 이 로드밸런서에 "드로우 커맨드 N의 인스턴스 [오프셋, 개수)"를 등록하는 것뿐이고, 실제 컬링은 전부 GPU에서 일어난다.
  • 이 설계가 말해주는 것: GPU 컬링의 오버헤드는 인스턴스 수에 정확히 비례한다(스레드 1개 = 인스턴스 1개). 아무리 컬링이 잘 되어 있어도 "검사 비용"은 인스턴스 수를 따라간다. 컬링은 드로우 비용을 아끼는 것이지, 검사 비용을 없애는 게 아니다.

6. GPU 로드밸런서 — "나는 몇 번째 인스턴스 담당?" (GPU 편)

Engine/Shaders/Private/InstanceCulling/InstanceCullingLoadBalancer.ush — InstanceCullingLoadBalancer_Setup() (약 164줄)

그룹의 64 스레드가 각자 "내가 배치의 어떤 아이템(인스턴스 스팬)의 몇 번째 인스턴스인지"를 shared memory 프리픽스 연산으로 찾는다.

// 각 스레드가 자기 아이템의 시작 위치(프리픽스 오프셋)에 자기 인덱스를 기록
if (GroupThreadIndex < GroupBatch.NumItems)
{
    FInstanceBatchItem WorkItem = UnpackItem(ItemBuffer[GroupBatch.FirstItem + GroupThreadIndex]);
    ItemIndex[WorkItem.BatchPrefixOffset] = GroupThreadIndex;
    Items[GroupThreadIndex] = WorkItem;
}
GroupMemoryBarrierWithGroupSync();

uint WorkItemIndex = 0U;
if (GroupBatch.NumItems < 4U)
{
    // 아이템이 적으면 선형 탐색 (동기화 비용 없음)
    for (int Index = int(GroupBatch.NumItems) - 1; Index >= 0; --Index) { /* ... */ }
}
else
{
    // 프리픽스 맥스: "내 스레드 인덱스 이전에 기록된 마지막 아이템 인덱스"를 이진 트리로 계산
    WorkItemIndex = PrefixMax(GroupThreadIndex);   // Hillis-Steele 스타일, 수동 언롤된 단계(log2(64)=6단계)
}

Setup.WorkItemIndex = WorkItemIndex;
Setup.Item = Items[WorkItemIndex];
// 내가 이 아이템의 몇 번째 인스턴스인가
Setup.LocalItemIndex = int(GroupThreadIndex - Setup.Item.BatchPrefixOffset);
Setup.bValid = Setup.LocalItemIndex >= 0 && Setup.LocalItemIndex < int(Setup.Item.NumInstances);
  • GPU에서는 스레드가 자기 워크를 "인덱스로 접근"할 수 없다. 그래서 **아이템 시작 위치에 스레드 인덱스를 심은 뒤 프리픽스 맥스(Hillis-Steele 스캔)**로 "나는 몇 번째 아이템 소속인가"를 그룹 전체가 동시에 계산한다. PrefixMax가 1→2→4→8→16→32 단계로 수동 언롤되어 있는 것(주석: UNROLL이 *=2를 못 알아챈다)은 전형적인 GPU 최적화 코드다.
  • 특수 경로가 3개나 준비되어 있다 — ① 아이템 수 = 그룹 크기(1:1 매칭, shared memory 연산 생략), ② 아이템 수 < 4(선형 탐색), ③ 싱글 인스턴스 모드(LOAD_BALANCER_SINGLE_INSTANCE_MODE, UnCulled 배치용: 모든 아이템이 1인스턴스이므로 프리픽스 자체가 불필요). 워크로드 특성에 따라 완전히 다른 코드 경로를 퍼뮤테이션으로 굽는 방식이다.
  • 이 로드밸런서가 존재하는 이유를 한 문장으로 요약하면: 가변 길이 컬링 워크를 고정 크기 스레드 그룹에 packing해서 GPU 점유율을 떨어뜨리지 않는 것. "드로우 커맨드당 스레드 그룹 1개 디스패치" 같은 나이브한 설계는 수백만 스레드의 90%가 노는 결과를 낸다.

7. C++ 패스 구성 — 어떤 셰이더가 언제 디스패치되는가

Engine/Source/Runtime/Renderer/Private/InstanceCulling/InstanceCullingContext.cpp — FInstanceCullingContext::BuildRenderingCommandsInternal() (약 745줄)

const bool bCullInstances = InstanceCullingManager != nullptr && CVarCullInstances.GetValueOnRenderThread() != 0;
const bool bOcclusionCullInstances = PrevHZB.IsValid() && IsOcclusionCullingEnabled();

// 인스턴스 ID 산출 버퍼 + indirect args 버퍼 생성
FRDGBufferRef InstanceIdsBuffer = GraphBuilder.CreateBuffer(..., TEXT("InstanceCulling.InstanceIdsBuffer"));
FRDGBufferRef DrawIndirectArgsRDG = GraphBuilder.CreateBuffer(IndirectArgsDesc, TEXT("InstanceCulling.DrawIndirectArgsBuffer"));
GraphBuilder.QueueBufferUpload(DrawIndirectArgsRDG, IndirectArgs.GetData(), ...);  // VertexCount 등 고정부는 CPU가 업로드
AddClearIndirectArgInstanceCountPass(GraphBuilder, ShaderMap, DrawIndirectArgsRDG); // InstanceCount = 0으로 클리어

// 컬링 뷰 데이터(메인 뷰/섀도/VSM) 바인딩
PassParametersTmp.ViewDataCullingParameters = InstanceCullingManager->ViewDataManager.GetCullingParameters(GraphBuilder);

// 두 개의 배치 프로세싱 모드에 대해 각각 디스패치
for (uint32 Mode = 0U; Mode < uint32(EBatchProcessingMode::Num); ++Mode)
{
    FInstanceProcessingGPULoadBalancer* LoadBalancer = LoadBalancers[Mode];
    if (!LoadBalancer->IsEmpty())
    {
        // ... 파라미터 복사, 로드밸런서 버퍼 업로드

        FBuildInstanceIdBufferAndCommandsFromPrimitiveIdsCs::FPermutationDomain PermutationVector;
        PermutationVector.Set<FSingleInstanceModeDim>(EBatchProcessingMode(Mode) == EBatchProcessingMode::UnCulled);
        PermutationVector.Set<FCullInstancesDim>(bCullInstances && EBatchProcessingMode(Mode) != EBatchProcessingMode::UnCulled);
        PermutationVector.Set<FOcclusionCullInstancesDim>(bOcclusionCullInstances);
        PermutationVector.Set<FStereoModeDim>(InstanceCullingMode == EInstanceCullingMode::Stereo);
        PermutationVector.Set<FInstanceCompactionDim>(bOrderPreservationEnabled);

        FComputeShaderUtils::AddPass(
            GraphBuilder,
            RDG_EVENT_NAME("CullInstances(%s)", BatchProcessingModeStr[Mode]),   // GPU 프로파일러의 CullInstances(UnCulled/Generic)
            ComputeShader, PassParameters,
            LoadBalancer->GetWrappedCsGroupCount()
        );
    }
}
  • indirect args 버퍼의 초기화 전략이 재미있다. 정점/인덱스 카운트 등 "고정부"는 CPU가 업로드하고(모든 인스턴스가 컬링되면 카운트 0으로 드로우 스킵), InstanceCount만 GPU가 클리어 패스로 0으로 만든 뒤 원자적으로 증가시킨다. CPU는 총 인스턴스 수를 알 필요가 없다.
  • 디스패치는 항상 2회 — UnCulled(싱글 인스턴스, 컬링 스킵 퍼뮤테이션)와 Generic(다중 인스턴스, 컬링 수행). 전편에서 봤던 "인스턴스 1개는 GPU 재컬링을 하지 않는다"가 퍼뮤테이션 레벨로 분리되어 있는 것이다. CullInstancesDim이 Mode != UnCulled 조건과 묶인 한 줄이 그 분기점이다.
  • GPU 프로파일러에서 BuildRenderingCommands(Culling=On) → CullInstances(Generic) 이벤트가 바로 이 디스패치다. 인스턴스 컬링 GPU 비용은 여기서 측정한다.
  • 오클루전 퍼뮤테이션(OCCLUSION_CULL_INSTANCES)은 PrevHZB — 이전 프레임의 HZB — 가 있을 때만 켜진다. 이전 프레임 깊이로 컬링하므로 카메라가 빠르게 움직이면 첫 프레임에 뚫리는(잘못 컬링된) 물체가 있을 수 있고, 그래서 기본값이 꺼져 있다(5.8 기준 r.InstanceCulling.OcclusionCull 0, 프리뷰).

8. 순서 보존 Compaction — 살아남은 인스턴스의 재배치 (선택 경로)

컬링 후의 InstanceIdsBuffer는 "살아남은 순서대로 뒤죽박죽" 기록된다(원자적 오프셋 분배라 순서가 없다). 대부분의 드로우는 순서와 무관하지만, 인스턴스 순서가 의미 있는 경우(파티클/투명체 등)가 있다.

같은 파일 (약 909줄)

if (bOrderPreservationEnabled && NumCompactionBlocks > 0)
{
    // Compaction phase one - prefix sum of the compaction "blocks"
    FComputeShaderUtils::AddPass(GraphBuilder, RDG_EVENT_NAME("Instance Compaction Phase 1"),
        ShaderMap->GetShader<FCalculateCompactBlockInstanceOffsetsCs>(), PassParameters,
        FComputeShaderUtils::GetGroupCountWrapped(DrawCommandCompactionData.Num()));

    // Compaction phase two - write instances to compact final location
    FComputeShaderUtils::AddPass(GraphBuilder, RDG_EVENT_NAME("Instance Compaction Phase 2"),
        ShaderMap->GetShader<FCompactVisibleInstancesCs>(), PassParameters,
        FComputeShaderUtils::GetGroupCountWrapped(NumCompactionBlocks));
}
  • 2단계 컴팩션: 1단계에서 각 블록(64인스턴스 단위)의 살아남은 수를 프리픽스 합으로 최종 오프셋 계산, 2단계에서 인스턴스를 원래 순서 위치로 산출(scatter). GPU 병렬 처리의 정석인 "카운트 → 스캔 → 산출" 패턴이 그대로 쓰였다.
  • r.InstanceCulling.AllowInstanceOrderPreservation(기본 1)가 이 경로를 제어한다. 순서 보존이 필요 없는 드로우 커맨드는 compaction 없이 산출 순서 그대로 그려지는 게 기본 — 필요한 것만 비용을 지불한다.

9. 드로우 — CPU는 모른다, GPU가 안다

Engine/Source/Runtime/Renderer/Private/MeshPassProcessor.cpp — FMeshDrawCommand::SubmitDrawIndirectEnd() (약 1408줄)

void FMeshDrawCommand::SubmitDrawIndirectEnd(const FMeshDrawCommand& MeshDrawCommand,
                                             const FMeshDrawCommandSceneArgs& SceneArgs, ...)
{
	FRHIBuffer* IndirectArgsBuffer = nullptr;
	uint32      IndirectArgsOffset = 0;

	// ... 커스텀 indirect args가 있으면 사용, 없으면 인스턴스 컬링이 만든 버퍼 사용
	if (SceneArgs.IndirectArgsBuffer != nullptr)
	{
		IndirectArgsBuffer = SceneArgs.IndirectArgsBuffer;      // InstanceCulling.DrawIndirectArgsBuffer
		IndirectArgsOffset = SceneArgs.IndirectArgsByteOffset;
	}

	// 모바일(UBO) 경로: 이 드로우의 인스턴스 데이터 시작 오프셋을 동적 오프셋으로
	if (IsUniformBufferStaticSlotValid(SceneArgs.BatchedPrimitiveSlot))
	{
		RHICmdList.SetUniformBufferDynamicOffset(SceneArgs.BatchedPrimitiveSlot, SceneArgs.PrimitiveIdOffset);
	}

	if (MeshDrawCommand.IndexBuffer)
	{
		RHICmdList.DrawIndexedPrimitiveIndirect(MeshDrawCommand.IndexBuffer, IndirectArgsBuffer, IndirectArgsOffset);
	}
	else
	{
		RHICmdList.DrawPrimitiveIndirect(IndirectArgsBuffer, IndirectArgsOffset);
	}
}
  • 드로우 시점의 CPU(RHI 스레드)가 하는 일은 버퍼와 오프셋을 가리키는 것뿐이다. InstanceCount는 GPU가 쓴 값 그대로 하드웨어가 읽는다. 전부 컬링되면 카운트 0이라 드로우는 사실상 무료로 스킵된다.
  • 버텍스 셰이더는 이 드로우의 시작 오프셋(PrimitiveIdOffset)부터 InstanceIdsBuffer에 기록된 인스턴스 ID를 읽어 GPU Scene에서 인스턴스 트랜스폼을 페치한다. 컬링 시 기록했던 CullingFlags(WPO 평가 여부 등)도 여기서 소비된다.
  • 이 구조가 "GPU 드라이븐"의 완성이다. 가시성 판정 → 인스턴스 나열 → 드로우 파라미터 결정 → 드로우 실행 전부 GPU 메모리 안에서 닫힌다. CPU의 역할은 전편에서 봤듯 "검사할 목록의 뼈대(프리미티브 → 드로우 커맨드 → 인스턴스 범위)를 만드는 것"까지다.

10. 정리: GPU 프러스텀 컬링의 실체와 시사점

소스에서 확인한 GPU 프러스텀 컬링의 전모:

  1. 스레드 1개 = 인스턴스 1개. GPU 로드밸런서가 가변 길이 인스턴스 스팬을 64스레드 그룹에 빈틈없이 패킹하고, 각 스레드는 shared memory 프리픽스 연산으로 자기 담당을 찾는다.
  2. 컬링 판정은 싼 것부터: 유효성 → 거리 → 화면 크기 → 글로벌 클립 플레인 → 프러스텀 → (옵션) HZB 오클루전.
  3. 프러스텀 테스트는 8코너 클립 공간 투영 + min3/max3 접기. 평면 방정식 6개 대신 SIMD에 유리하고, HZB/VSM에 재사용되는 화면 사각형까지 함께 계산한다. 근/원평면은 w공간에서 나눗셈 없이 판정한다.
  4. 결과는 원자적 카운트 하나로 기록: indirect args의 InstanceCount를 InterlockedAdd로 올리면서 산출 오프셋을 받아 인스턴스 ID를 산출. 순서가 필요하면 2단계 컴팩션.
  5. 드로우는 indirect call: CPU는 인스턴스 수를 모른 채 GPU가 쓴 카운트로 그린다.

시사점:

  • GPU 컬링도 전수 조사다. 인스턴스 수만큼 스레드가 돈다. ISM 하나에 인스턴스 100만 개를 넣으면 CPU relevance는 1회지만 GPU 검사 비용은 100만 스레드다. "ISM이 만능"이 아니라는 뜻.
  • 바운드 정확도가 GPU 컬링 효율을 좌우한다. 프러스텀 판정은 전부 인스턴스 로컬 바운드 기반이다. 바운드가 부푼 인스턴스는 GPU에서도 살아남아 픽셀 비용을 낸다. WPO를 쓰는 머티리얼은 WPO 반경만큼 바운드가 커지고, 멀리서 WPO가 꺼지면 컬링이 다시 타이트해진다(컬링과 머티리얼의 협력 구조).
  • 측정 지점은 정해져 있다. GPU 프로파일러에서 BuildRenderingCommands → CullInstances(Generic) → Instance Compaction Phase 1/2. 여기가 크면 인스턴스 수 자체를 줄여야 한다(LOD/컬 거리/HLOD).

관련 콘솔 변수 (5.8 기준 기본값):

콘솔 변수 기본값 의미

r.CullInstances 1 GPU 인스턴스 컬링(프러스텀 등) on/off
r.InstanceCulling.OcclusionCull 0 인스턴스 단위 HZB 오클루전 컬링(이전 프레임 HZB, 프리뷰)
r.InstanceCulling.ForceInstanceCulling 0 싱글 인스턴스도 강제로 컬링 경유
r.InstanceCulling.AllowInstanceOrderPreservation 1 순서 보존 compaction 허용

11. 마무리

GPU 프러스텀 컬링은 마법이 아니라 정교한 병렬 자료구조다. CPU는 검사 대상의 뼈대(프리미티브 → 드로우 커맨드 → 인스턴스 범위)를 전달한다. GPU는 로드밸런서로 작업을 스레드에 배분하고, 8개 코너를 클립 공간에 투영해 프러스텀을 판정한다. 이어 원자적 카운트로 살아남은 인스턴스만 나열해 indirect draw를 구성한다.

전편과 이번 편을 종합하면 결론은 하나다. 컬링은 CPU와 GPU 어느 쪽에서도 공짜가 아니다. CPU는 프리미티브 수만큼, GPU는 검사할 인스턴스 수만큼 비용을 부담한다. 따라서 성능을 개선하려면 병합, LOD, 컬 거리, HLOD 같은 콘텐츠 설계로 검사 대상의 총량을 줄여야 한다. 엔진 소스를 살펴보면 이 가이드라인이 어떤 루프에서 비롯되는지도 확인할 수 있다.