UE5는 GPU Scene, GPU 인스턴스 컬링, Nanite 등을 통해 GPU 주도 렌더링으로 발전하며 그리는 비용의 상당 부분을 GPU로 넘겼다. 하지만 GPU에서 컬링한다고 해서 CPU가 한가해지는 것은 아니다. 엔진은 매 프레임 렌더 스레드와 병렬 태스크에서 씬에 등록된 프리미티브 정보를 직접 순회하며 "수집"한다. 이 수집 비용은 화면에 보이는 물체 수가 아니라 씬에 존재하는 프리미티브의 총수에 비례하는 경우가 많고, 뷰(카메라) 수에 따라 증가한다. 프리미티브가 수십만 개에 이르는 대형 레벨에서 렌더 스레드가 먼저 병목에 이르는 이유가 여기에 있다.
이 글에서는 UE 5.8.3 엔진 소스(Engine/Source/Runtime/Renderer/Private 기준)의 실제 구현을 살펴보고, ① CPU가 어떤 경로로 프리미티브를 수집하는지, ② 각 비용이 무엇에 비례해 증가하는지, ③ 레벨 디자인과 콘텐츠 측면에서 어떻게 균형을 잡아야 하는지 정리한다. 인용한 코드는 실제 엔진 소스에서 발췌했으며, 라인 번호는 5.8.3 기준이다.
1. 한 프레임의 지도: CPU는 어디서 프리미티브를 만지나
InitViews를 시작으로 하는 가시성 파이프라인에서 CPU가 프리미티브를 "수집"하는 지점은 크게 네 군데다.
- 프러스텀 컬 — 씬의 모든 프리미티브를 뷰마다 순회하며 가시성 비트맵을 만든다. 작업량은 로드된 프리미티브 수에 비례한다.
- Relevance 계산 — 컬링을 통과한 프리미티브마다 프록시의 가상 함수를 호출하고, 패스별(BasePass/DepthPass/Velocity 등) 메시 드로우 커맨드를 연결한다.
- 다이내믹 메시 수집(GDME) — 다이내믹 relevance를 가진 프리미티브는 매 프레임 GetDynamicMeshElements로 메시 배치를 만들고 GPU Scene에 올릴 데이터를 뷰별로 수집한다.
- GPU Scene 업데이트 + 인스턴스 컬링 피드 — 이동·변경된(dirty) 프리미티브의 셰이더 데이터를 CPU에서 패킹해 업로드하고, GPU 인스턴스 컬링을 위해 드로우 커맨드마다 인스턴스 범위를 기록한다.
핵심은 다음과 같다. GPU 컬링은 '그리는 비용'을 줄여줄 뿐, '수집하는 비용'은 여전히 CPU 몫이며, 그 분모는 대부분 Scene.Primitives.Num()이다. 이제 각 지점의 코드를 보자.
2. 모든 비용의 분모 — Scene.Primitives.Num()
Engine/Source/Runtime/Renderer/Private/SceneVisibility.cpp — FVisibilityTaskConfig::FVisibilityTaskConfig() (약 3571줄)
FVisibilityTaskConfig::FVisibilityTaskConfig(const FScene& Scene, TConstArrayView<FViewInfo*> Views)
{
// (중략) 태스크 스케줄 결정
NumTestedPrimitives = uint32(Scene.Primitives.Num());
NumVisiblePrimitives = 0u;
if (Scene.PrimitivesAlwaysVisibleOffset != ~0u)
{
NumTestedPrimitives = Scene.PrimitivesAlwaysVisibleOffset;
NumVisiblePrimitives = uint32(Scene.Primitives.Num()) - NumTestedPrimitives;
// Ensure that the dword alignment code is correct and we never have partial dword offsets
check(uint32(NumTestedPrimitives % NumBitsPerDWORD) == 0u);
}
// (중략) 워커 스레드 수에 맞춰 태스크 크기 분할
const uint32 NumWorkerThreads = FMath::Min(LowLevelTasks::FScheduler::Get().GetNumWorkers(), 16u);
const uint32 NumFrustumCullTasksPerThread = 2;
// ...
}
리뷰
- NumTestedPrimitives = Scene.Primitives.Num() — 프러스텀 컬의 총량을 결정하는 핵심 코드다. 작업량은 화면에 보이는 개수가 아니라 월드에 로드된 프리미티브 수에 따라 달라진다. 월드 파티션으로 셀을 잘게 나눠도, 로드된 셀 안의 프리미티브 총수가 많으면 이 비용은 줄지 않는다.
- PrimitivesAlwaysVisibleOffset — bAlwaysVisible로 표시된 프리미티브는 배열 뒤쪽에 배치되어 프러스텀 컬 대상에서 제외된다(아래에서 다룬다). 단, 컬링만 건너뛸 뿐 그리는 비용은 그대로다.
- 태스크 분할 로직(NumWorkerThreads, NumFrustumCullTasksPerThread)은 이 비용을 분산할 뿐 삭제하지 않는다. 워커가 아무리 많아도 총 CPU 사이클은 O(프리미티브 수)이고, 스케줄이 렌더 스레드로 돌아가는 순간(r.Visibility.TaskSchedule 0) 렌더 스레드 시간에 직접 붙는다.
- 이 루프는 뷰마다 실행된다. 분할화면 2인 플레이는 물론이고, 씬 캡처·리플렉션·섀도 깊이 렌더링 같은 추가 뷰도 가시성 비용을 곱연산한다.
레벨 디자인 시사점 — "한 번에 메모리에 올라와 있는 프리미티브 수"가 곧 렌더 스레드 예산이다. 월드 파티션 셀 크기·로딩 범위 설정은 텍스처 스트리밍 관리만이 아니라 CPU 수집 비용 관리라는 관점에서도 봐야 한다.
3. 프러스텀 컬 비트 루프 — 프리미티브당 몇 번의 터치
Engine/Source/Runtime/Renderer/Private/SceneVisibility.cpp — FrustumCull() (약 860줄)
static int32 FrustumCull(const FScene& Scene, FViewInfo& View, ...)
{
// ...
for (int32 WordIndex = TaskWordOffset; WordIndex < TaskWordOffset + int32(TaskConfig.FrustumCull.NumWordsPerTask) && WordIndex * NumBitsPerDWORD < BitArrayNumInner; WordIndex++)
{
uint32 Mask = 0x1;
uint32 VisBits = 0;
// ...
for (int32 BitSubIndex = 0; BitSubIndex < NumBitsPerDWORD && WordIndex * NumBitsPerDWORD + BitSubIndex < BitArrayNumInner; BitSubIndex++, Mask <<= 1)
{
int32 Index = WordIndex * NumBitsPerDWORD + BitSubIndex;
bool bPrimitiveIsHidden = IsPrimitiveHidden(Scene, View, Index, Flags);
bool bIsVisible = Flags.bShouldVisibilityCull ? true : (VisBits & Mask) == Mask;
bIsVisible = bIsVisible && !bPrimitiveIsHidden;
const FPrimitiveBounds& RESTRICT Bounds = Scene.PrimitiveBounds[Index];
// Zero sized bounds indicates that we are not visible.
bIsVisible &= Bounds.BoxSphereBounds.SphereRadius > 0;
if (Flags.bShouldVisibilityCull && bIsVisible)
{
// (중략) HLOD 강제 표시/숨김 처리
if (Flags.bUseVisibilityOctree)
{
// If the parent octree node was completely contained by the frustum,
// there is no need do an additional frustum test on the primitive bounds
uint32 OctreeNodeIndex = Scene.PrimitiveOctreeIndex[Index];
bIsVisible = (*VisibleNodes)[OctreeNodeIndex * 2];
bPartiallyOutside = (*VisibleNodes)[OctreeNodeIndex * 2 + 1];
}
if (bIsVisible)
{
// ... (중략) 커스텀 컬링 볼륨, 프러스텀 6평면 테스트
bIsVisible = !bPartiallyOutside || IsPrimitiveVisible(View, PermutedPlanePtr, ViewCullingFrustum, Bounds, VisibilityId, Flags);
}
}
// Screen size culling next
if (bIsVisible && IsScreenSizeCullingElligible(View, Bounds.BoxSphereBounds, MinScreenBoundsRadiusSquared))
{
bIsVisible = !IsScreenSizeCulled(View, Bounds.BoxSphereBounds, MinScreenBoundsRadiusSquared);
}
// (중략) 거리 컬링, LOD 페이드 마킹, 가시 비트맵 기록
}
}
// ...
}
- 프리미티브마다 히든 여부 확인, 바운드 로드, (옥트리 노드 조회), 프러스텀 6평면 테스트, 화면 크기·거리 컬링, LOD 페이드 비트 기록을 수행한다. 각 작업은 가볍지만, 프리미티브 수와 뷰 수가 함께 늘면 캐시 라인 왕복이 병목이 될 수 있다. RESTRICT 포인터와 워드 단위 비트 패킹을 적용한 점도 이 루프의 비용을 보여준다.
- r.Visibility.FrustumCull.UseOctree(기본값 0)을 켜면 옥트리 노드 단위로 먼저 컬링(CullOctree())한다. 부모 노드가 프러스텀 안에 완전히 포함되면 개별 프러스텀 테스트를 생략할 수 있다. 다만 5.8의 기본 경로에서는 여전히 모든 프리미티브를 조사한다.
- 이 단계의 컬링 판정은 전부 Scene.PrimitiveBounds[Index]의 바운드를 기반으로 한다. 바운드가 실제보다 크게 부풀어 있으면(과도한 스케일, 지오메트리 변형, 본/소켓 포함) 컬링은 통과시키고 다음 단계(relevance)로 넘긴다. CPU 비용 관점에서 "바운드가 부정확한 프리미티브"는 "컬링이 안 되는 프리미티브"나 다름없다.
레벨 디자인 시사점 — 컴포넌트 하나가 프리미티브 하나다. 풀 하나를 스태틱 메시 컴포넌트 2,000개로 심는 것과 ISM(Instanced Static Mesh) 1개 컴포넌트로 심는 것은 GPU뿐 아니라 이 루프에서 2,000배 차이난다.
추가 : 폴리지(Foliage) 시스템은 이 병합을 자동으로 해준다.
- 폴리지 툴은 타입(×레벨)마다 컴포넌트를 정확히 하나 만든다 — FFoliageStaticMesh::CreateNewComponent()(Foliage/Private/InstancedFoliage.cpp). 이 컴포넌트 UFoliageInstancedStaticMeshComponent는 UHierarchicalInstancedStaticMeshComponent, 즉 HISM을 상속한다(FoliageInstancedStaticMeshComponent.h). 풀 10만 본을 심어도 프리미티브는 1개다.
- “200개”의 정체는 클러스터 트리의 리프(말단 노드) 예산이다:
// Engine/Source/Runtime/Engine/Private/HierarchicalInstancedStaticMesh.cpp
int32 UHierarchicalInstancedStaticMeshComponent::DesiredInstancesPerLeaf()
{
int32 LOD0Verts = GetVertsForLOD(0);
int32 VertsToSplit = CVarMinVertsToSplitNode.GetValueOnAnyThread(); // foliage.MinVertsToSplitNode, 기본 8192
if (LOD0Verts)
{
return FMath::Clamp(VertsToSplit / LOD0Verts, 1, 1024);
}
return 16;
}
- 리프 하나에 드는 인스턴스 수 = 8192 ÷ LOD0 버텍스 수(1~1024 클램프). LOD0 버텍스 약 41개짜리 로우폴리 풀 메시면 정확히 200이 나온다. 이 트리는 뷰별 CPU 컬링·LOD 판정에 쓰이는 컴포넌트 내부 자료구조일 뿐, 프리미티브 수를 늘리지 않는다.
- 예외는 랜드스케이프 그래스(Grass): 랜드스케이프 컴포넌트 × 그래스 타입당 HISM 1개를 만들고(Landscape/Private/LandscapeGrass.cpp), 인스턴스가 grass.MaxInstancesPerComponent(기본 65,536)를 넘으면 서브섹션으로 분할한다. 즉 그래스의 프리미티브 수는 보이는 랜드스케이프 컴포넌트 수 × 그래스 타입 수에 비례한다 — 원칙 1이 그대로 적용되는 영역이므로, 그래스 레이어가 많은 넓은 랜드스케이프에서는 stat initviews로 프리미티브 증가를 확인하자.
폴리지/그래스는 “ISM 병합을 엔진이 대신해주는 공식 도구”다. 수동으로 스태틱 메시 컴포넌트를 까는 대신 이 시스템을 쓰라는 것이 위 시사점의 실무적 결론이다.
4. Relevance — 살아남은 프리미티브의 가상 호출
Engine/Source/Runtime/Renderer/Private/SceneVisibility.cpp — FRelevancePacket::ComputeRelevance() (약 1520줄)
void FRelevancePacket::ComputeRelevance(FDynamicPrimitiveIndexList& DynamicPrimitiveIndexList)
{
TRACE_CPUPROFILER_EVENT_SCOPE(ComputeViewRelevance);
// ...
for (int32 InputPrimsIndex = 0; InputPrimsIndex < Input.Prims.Num(); ++InputPrimsIndex)
{
int32 BitIndex = Input.Prims[InputPrimsIndex];
if (InputPrimsIndex + 1 < Input.Prims.Num())
{
int32 NextBitIndex = Input.Prims[InputPrimsIndex + 1];
// Prefetch the next primitive / proxy pair for the next loop.
FPlatformMisc::Prefetch(Scene.Primitives[NextBitIndex]);
FPlatformMisc::Prefetch(Scene.PrimitiveSceneProxies[NextBitIndex]);
}
FPrimitiveSceneInfo* PrimitiveSceneInfo = Scene.Primitives[BitIndex];
FPrimitiveViewRelevance& ViewRelevance = const_cast<FPrimitiveViewRelevance&>(View.PrimitiveViewRelevanceMap[BitIndex]);
const FPrimitiveSceneProxy* PrimitiveSceneProxy = PrimitiveSceneInfo->Proxy;
// Prefetch the scene data and static mesh relevance array now while we call GetViewRelevance to reduce memory waits.
FPlatformMisc::Prefetch(PrimitiveSceneInfo->StaticMeshRelevances.GetData());
FPlatformMisc::Prefetch(PrimitiveSceneInfo->GetSceneData());
ViewRelevance = PrimitiveSceneProxy->GetViewRelevance(&View);
// ...
}
// (중략) 이어서 StaticMeshRelevances 순회하며 패스별 캐시된 메시 드로우 커맨드 연결
}
- 컬링을 통과한 프리미티브마다 GetViewRelevance() 가상 함수 호출이 들어간다. 프록시는 힙에 흩어져 할당되므로 전형적인 포인터 체이싱 구조인데, 엔진이 루프 안에서 다음 프록시를 프리패치하는 것부터가 이 비용의 크기를 보여주는 증거다.
- 여기서 비용이 드는 대상은 "보이는 프리미티브"다. 즉 이 단계는 씬 전체가 아니라 컬링 생존자 수에 비례한다. 따라서 밀도가 높은 시점(예: 내려다보는 카메라, 넓은 광장)에서는 ComputeViewRelevance 시간이 눈에 띄게 증가한다.
- 이어지는 StaticMeshRelevances 순회에서는 BasePass/DepthPass/Velocity 등 패스별로 캐시된 메시 드로우 커맨드를 뷰의 드로우 리스트에 연결한다. 스태틱 프리미티브는 커맨드 생성이 이미 캐시되어 있어 연결만 하지만, 생존자 × 패스 × LOD 조각 수만큼 CPU 작업이 발생한다.
레벨 디자인 시사점 — 화면에 많이 보이게 두는 모든 결정이 이 비용을 키운다. 최소 화면 크기(MinScreenSize), 컬 거리(Desired Max Draw Distance), HLOD 전환 거리는 GPU 절약이 아니라 CPU relevance 절약이라는 관점에서 설정해야 한다.
5. 다이내믹 경로 — 매 프레임 수집되는 메시 배치
Engine/Source/Runtime/Renderer/Private/SceneVisibility.cpp — FDynamicMeshElementContext::LaunchAsyncTask() (약 4167줄)
UE::Tasks::FTask FDynamicMeshElementContext::LaunchAsyncTask(FDynamicPrimitiveIndexQueue* PrimitiveIndexQueue, UE::Tasks::ETaskPriority TaskPriority)
{
return Pipe.Launch(UE_SOURCE_LOCATION, [this, PrimitiveIndexQueue]
{
FTaskTagScope Scope(ETaskTag::EParallelRenderingThread);
SCOPED_NAMED_EVENT(GatherDynamicMeshElements, FColor::Magenta);
FDynamicPrimitiveIndex PrimitiveIndex;
while (PrimitiveIndexQueue->Pop(PrimitiveIndex))
{
GatherDynamicMeshElementsForPrimitive(Primitives[PrimitiveIndex.Index], PrimitiveIndex.ViewMask);
}
// ...
}, TaskPriority);
}
그리고 수집된 다이내믹 프리미티브의 GPU Scene 데이터는 뷰별 컬렉터에 쌓인다.
Engine/Source/Runtime/Renderer/Private/GPUScene.cpp — FGPUScenePrimitiveCollector::Add() (약 225줄)
void FGPUScenePrimitiveCollector::Add(
const FMeshBatchDynamicPrimitiveData* MeshBatchData,
const FPrimitiveUniformShaderParameters& PrimitiveShaderParams,
uint32 NumInstances,
uint32& OutPrimitiveIndex,
uint32& OutInstanceSceneDataOffset)
{
// Lazy allocation of the upload data to not waste space and processing if none was needed.
if (UploadData == nullptr)
{
UploadData = AllocateUploadData();
}
const int32 PrimitiveIndex = UploadData->PrimitiveData.Num();
FPrimitiveData& PrimitiveData = UploadData->PrimitiveData.AddDefaulted_GetRef();
// ...
PrimitiveData.ShaderParams = &PrimitiveShaderParams;
PrimitiveData.NumInstances = NumInstances;
PrimitiveData.LocalInstanceSceneDataOffset = UploadData->TotalInstanceCount;
// ...
UploadData->TotalInstanceCount += NumInstances;
UploadData->InstancePayloadDataFloat4Count += PayloadFloat4Stride * NumInstances;
// ...
}
- 다이내믹 relevance를 가진 프리미티브는 매 프레임 GetDynamicMeshElements()(프록시 가상 호출)로 FMeshBatch를 생성하고, 프리미티브 유니폼 파라미터를 뷰별 컬렉터에 복사해서 업로드 대기시킨다. 스태틱 경로의 "캐시된 메시 드로우 커맨드"와 정반대로, 매 프레임 재수집되는 경로다.
- "다이내믹"의 기준은 모빌리티만이 아니다. 무버블 지오메트리, 초당 다시 그려야 하는 커스텀 드로우, 뷰 종속 LOD 등도 이 경로를 탄다.
- GatherDynamicMeshElements 네임드 이벤트가 프로파일러에서 보이면 이 비용이 노출된 것이다.
레벨 디자인 시사점 — "움직이지 않는 것은 무조건 Static으로"는 클리셰인데, 위 코드가 그 이유다. 무버블 1개는 매 프레임 GDME + 컬렉터 복사 비용을 지불하고, 스태틱 1개는 최초 1회 캐시 비용만 지불한다.
6. GPU Scene 업데이트 — dirty 목록 설계와 런타임 화질 전환의 함정
GPU Scene은 셰이더가 프리미티브 데이터를 읽는 GPU 상의 구조화 버퍼다. CPU는 매 프레임 변경분(dirty)만 패킹해서 올린다.
Engine/Source/Runtime/Renderer/Private/GPUScene.cpp — FGPUScene::AddPrimitiveToUpdate() (약 1755줄)
void FGPUScene::AddPrimitiveToUpdate(FPersistentPrimitiveIndex PersistentPrimitiveIndex, EPrimitiveDirtyState DirtyState)
{
if (bIsEnabled && PersistentPrimitiveIndex.IsValid())
{
ResizeDirtyState(PersistentPrimitiveIndex.Index + 1);
// Make sure we aren't updating same primitive multiple times.
if (PrimitiveDirtyState[PersistentPrimitiveIndex.Index] == EPrimitiveDirtyState::None)
{
PrimitivesToUpdate.Add(PersistentPrimitiveIndex);
}
// ...
PrimitiveDirtyState[PersistentPrimitiveIndex.Index] = NewState;
}
}
그런데 이 델타 업데이트 설계에 "전체 재업로드"로 돌아가는 스위치가 숨어 있다.
같은 파일 — FGPUScene::UpdateInternal() (약 769줄)
if (CVarGPUSceneViewDistanceScaleWorkaround.GetValueOnRenderThread())
{
if (GetCachedScalabilityCVars().ViewDistanceScale != TrackedViewDistanceScle)
{
bUpdateAllPrimitives = true;
}
TrackedViewDistanceScle = GetCachedScalabilityCVars().ViewDistanceScale;
}
// ...
if ((CVarGPUSceneUploadEveryFrame.GetValueOnRenderThread() != 0) || bUpdateAllPrimitives)
{
PrimitivesToUpdate.Reset();
ResizeDirtyState(Scene.GetMaxPersistentPrimitiveIndex());
InstanceRangesToClear.Reset(Scene.Primitives.Num());
for (FPrimitiveSceneInfo *PrimitiveSceneInfo : Scene.Primitives)
{
PrimitiveDirtyState[PrimitiveSceneInfo->GetPersistentIndex().Index] |= EPrimitiveDirtyState::ChangedAll;
PrimitivesToUpdate.Add(PrimitiveSceneInfo->GetPersistentIndex());
InstanceRangesToClear.Add(FInstanceRange{ /* ... */ });
}
bUpdateAllPrimitives = false;
}
- transform이 바뀐 프리미티브(UpdatePrimitiveTransform)는 매 프레임 dirty 목록에 들어가고, 그 인스턴스 수만큼 데이터를 다시 패킹·업로드한다. 무버블 액터 수백 개가 흔들리는 씬에서 UpdateGPUScene 시간이 커지는 구조다.
- 실무적으로 특히 주의할 부분은 두 번째 블록이다. ViewDistanceScale(스케일러비티의 View Distance) 값이 바뀌면 씬의 모든 프리미티브를 다시 업로드한다. 런타임 화질 프리셋 전환(예: 설정 메뉴에서 "거리" 슬라이더)이 있는 게임이라면, 전환 직후 프레임에 GPU Scene 풀 업로드 + 인스턴스 클리어 스파이크가 발생한다. 컷신이나 게임 플레이 중 자동 화질 전환을 계획하고 있다면 이 동작을 고려해 설계를 재검토할 필요가 있다.
- r.GPUScene.UploadEveryFrame(기본 0)은 매 프레임 전체 업로드를 강제하는 디버그 스위치다. 켜져 있으면 프로파일 결과가 왜곡되니 측정 전 확인하자.
레벨 디자인 시사점 — 무버블은 "필요한 것만", 런타임 화질 전환은 "로딩/일시정지 시점에". 이 두 가지가 GPU Scene 관점에서 프리미티브 수집 비용을 지배한다.
7. CPU 패킹 — 프록시에서 셰이더 데이터로
dirty 목록에 들어간 프리미티브는 렌더 그래프의 셋업 태스크에서 CPU가 직접 패킹한다.
Engine/Source/Runtime/Renderer/Private/GPUScene.cpp — FGPUScene::UploadGeneral() (약 1161줄) + FUploadDataSourceAdapterScenePrimitives::GetPrimitiveShaderData()
void FGPUScene::UploadGeneral(...)
{
const int32 NumPrimitiveDataUploads = UploadDataSourceAdapter.NumPrimitivesToUpload();
if (!NumPrimitiveDataUploads)
{
return;
}
const int32 ParallelExecuteThreshold = FApp::ShouldUseThreadingForPerformance() ? CVarGPUSceneParallelUpdate.GetValueOnRenderThread() : 0;
// ...
// 기본 임계값 2048 (r.GPUScene.ParallelUpdate): 업로드 항목이 이 값을 넘으면 병렬화
const bool bExecuteInParallel = ParallelExecuteThreshold > 0 && (TaskContext.NumInstancePayloadDataUploads > ParallelExecuteThreshold || TaskContext.NumPrimitiveDataUploads > ParallelExecuteThreshold);
// ...
}
// 업로드 어댑터 — 프리미티브 1개당 셰이더 데이터를 프록시에서 직접 패킹
FORCEINLINE_GPUSCENE void GetPrimitiveShaderData(int32 ItemIndex, FVector4f* RESTRICT OutData) const
{
const FPersistentPrimitiveIndex PersistentPrimitiveIndex = PrimitivesToUpdate[ItemIndex];
const int32 PrimitiveID = Scene.GetPrimitiveIndex(PersistentPrimitiveIndex);
if (ensure(Scene.PrimitiveSceneProxies.IsValidIndex(PrimitiveID)))
{
const FPrimitiveSceneProxy* RESTRICT PrimitiveSceneProxy = Scene.PrimitiveSceneProxies[PrimitiveID];
FPrimitiveSceneShaderData::BuildDataFromProxy(PrimitiveSceneProxy, OutData);
}
}
리뷰
- FPrimitiveSceneShaderData::BuildDataFromProxy()는 프록시가 흩어져 들고 있던 트랜스폼·바운드·LOD 정보 등을 FVector4f 배열(구조화 버퍼 레이아웃)로 짜맞추는 순수 CPU 작업이다. 업데이트되는 프리미티브 수 × 인스턴스 수에 비례한다.
- r.GPUScene.ParallelUpdate(기본 2048)가 이 루프를 병렬로 돌려주지만, 임계값을 넘기 전까지는 렌더 스레드 셋업 태스크에서 직렬로 돈다. dirty 프리미티브가 적으면 직렬이 더 빠르다는 판단의 임계값이고, 어느 쪽이든 총 작업량 자체는 줄어들지 않는다.
- CSV 스탯 GPUSceneInstanceCount, 스탯 타이머 UpdateGPUScene(STAT_UpdateGPUSceneTime)이 이 비용을 확인할 수 있는 지표다.
8. GPU 인스턴스 컬링의 CPU 피드 — 컬링은 GPU, 목록은 CPU
GPU 인스턴스 컬링이 켜져 있으면(기본값은 켜짐, r.CullInstances 1) 실제 컬링은 컴퓨트 셰이더가 수행한다. 그런데 "어떤 인스턴스 범위를 어떤 드로우 커맨드가 가져갈지"는 CPU가 드로우 커맨드마다 기록해야 한다.
Engine/Source/Runtime/Renderer/Private/InstanceCulling/InstanceCullingContext.cpp — FInstanceCullingContext::AddInstancesToDrawCommand() (약 309줄)
void FInstanceCullingContext::AddInstancesToDrawCommand(uint32 IndirectArgsOffset, int32 InstanceDataOffset, uint32 RunOffset, uint32 NumInstances, EInstanceFlags InstanceFlags)
{
// ...
uint32 Payload = (bDynamicInstanceDataOffset ? INSTANCE_CULLING_DYNAMIC_INSTANCE_DATA_OFFSET_BIT_MASK : 0U);
if (bPreserveInstanceOrder)
{
// We need to provide full payload data for these instances
// ...
PayloadData.Emplace(bDynamicInstanceDataOffset, IndirectArgsOffset, InstanceDataOffset, RunOffset, DrawCommandCompactionData.Num());
}
else
{
// Conserve space by packing the relevant payload information into the dword
Payload |= (IndirectArgsOffset << INSTANCE_CULLING_PAYLOAD_NUM_COMMON_BITS);
}
// We special-case the single-instance (i.e., regular primitives) as they don't need culling (again), except where explicitly specified.
// In actual fact this is not 100% true because dynamic path primitives may not have been culled.
EBatchProcessingMode Mode = (NumInstances == 1 && !bForceInstanceCulling) ? SingleInstanceProcessingMode : EBatchProcessingMode::Generic;
LoadBalancers[uint32(Mode)]->Add(uint32(InstanceDataOffset), NumInstances, Payload);
TotalInstances += NumInstances;
}
- 주석이 핵심을 말해준다. 인스턴스 1개짜리(일반 프리미티브)는 GPU에서 다시 컬링하지 않는다. 어차피 프러스텀 컬을 통과해 온 단일 인스턴스를 다시 컬링하는 건 낭비이기 때문이다. 반대로 다중 인스턴스(ISM/HISM)는 "인스턴스 범위"로 등록되어 GPU에서 개별 컬링된다.
- CPU 비용은 드로우 커맨드 수에 비례한다. 풀을 ISM 컴포넌트 하나로 배치하면 CPU 피드 작업은 크게 줄고, 개별 인스턴스 컬링은 GPU가 수행한다. 개별 스태틱 메시 2,000개로 심으면 피드 2,000회에 앞의 프러스텀 컬·relevance 2,000회까지 더해진다.
- GPU 컬링이 아무리 좋아도 "CPU가 수집해야 하는 목록의 길이"를 줄여주지는 않는다는 것 — 이 글의 테제를 가장 잘 보여주는 지점이다.
- 참고로 인스턴스 단위 오클루전 컬링(r.InstanceCulling.OcclusionCull)은 5.8 기준 기본값 0(프리뷰)이다. HZB 기반 프리미티브 오클루전 컬링과 별개의 실험적 경로다.
9. 그래서 레벨 디자인은 어떻게? — 6가지 원칙
지금까지의 코드에서 나온 비용 구조를 레벨 디자인/콘텐츠 결정으로 되돌아보면 다음과 같다.
원칙 1. 프리미티브 수가 모든 비용의 분모다
Scene.Primitives.Num()은 프러스텀 컬의 총량이자 GPU Scene 풀 업로드의 총량이다. 반복 배치는 ISM/HISM으로 병합하고, 병합 가능한 스태틱 메시는 Merge Actors로 합치고, 대형 환경 메시는 Nanite로(프록시·컬링 구조가 아예 다른 경로) 가져가라. "컴포넌트 수 예산"을 레벨 디자인 가이드라인에 명시하는 팀들이 많은 이유가 이것이다.
원칙 2. 월드 파티션·레벨 스트리밍은 CPU 수집 비용 관리 도구다
셀 크기와 로딩 범위는 텍스처 메모리뿐 아니라 "동시에 존재하는 프리미티브 수", 즉 렌더 스레드 CPU 예산을 결정한다. 로드 범위가 지나치게 넓은 월드는 GPU 사용률이 낮더라도 CPU가 먼저 병목에 이를 수 있다.
원칙 3. Static으로 될 수 있는 것은 Static으로
스태틱 프리미티브는 메시 드로우 커맨드가 캐시되어 relevance에서 "연결"만 하고, GDME를 거치지 않으며, GPU Scene 업데이트도 최초 1회다. 무버블은 매 프레임 dirty 재패킹 대상이 된다(코드 리뷰 ⑤⑥). 무버블 지정은 "라이팅/그림자 품질 옵션"이 아니라 "매 프레임 CPU 비용"이라는 관점에서 최소화하라.
원칙 4. 화면에서 일찍 죽여라 — 컬 거리와 최소 화면 크기
relevance 비용은 "컬링 생존자 수"에 비례한다(코드 리뷰 ③). Desired Max Draw Distance, 머티리얼·프리미티브 MinScreenSize, 컬 디스턴스 볼륨, HLOD 전환 거리는 GPU 부하를 줄이는 데 그치지 않고 CPU relevance 계산과 드로우 커맨드 연결 비용도 절감한다. 특히 돌, 풀, 잔해처럼 작은 소품에는 MinScreenSize 설정을 우선 고려하자.
원칙 5. 바운드를 믿을 수 있게 관리하라
프러스텀·거리·화면 크기 컬링 판정은 전부 PrimitiveBounds 기반이다(코드 리뷰 ②). 0 스케일/과도한 스케일, 지오메트리가 크게 변형되는 세팅, 바운드를 부풀리는 소켓·본 체인은 컬링 정확도를 떨어뜨려 CPU 생존자를 늘린다. 스카이돔처럼 컬링이 무의미한 단일 대형 프리미티브는 bAlwaysVisible 검토 대상이다 — 단, 이 플래그는 프러스텀 컬만 건너뛸 뿐(코드 리뷰 ①의 PrimitivesAlwaysVisibleOffset) 그리는 비용과 relevance는 그대로다. 남용하면 안 된다.
원칙 6. 런타임 화질 전환의 숨은 비용을 알라
ViewDistanceScale 변경은 GPU Scene 전체 재업로드를 유발한다(코드 리뷰 ⑤). 설정 변경은 일시정지/로딩 중에, 자동 스케일러비티(예: 배터리 절전) 전환은 컷신·전투 중이 아닌 시점에 트리거되게 설계하라.
10. 프로파일링 치트시트
관측 지점 도구 보는 것
| 프러스텀 컬 | Unreal Insights / Trace | SceneVisibility_FrustumCull 이벤트, 뷰당 총량 |
| Relevance | stat initviews, Insights | ComputeViewRelevance 시간, 보이는 프리미티브 수 |
| 다이내믹 수집 | Insights | GatherDynamicMeshElements / DynamicPrimitive 이벤트 |
| GPU Scene 업데이트 | CSV, Insights | UpdateGPUScene(STAT_UpdateGPUSceneTime), CSV GPUSceneInstanceCount |
| GPU 컬링 자체 | GPU 프로파일러 | BuildRenderingCommands(Culling=On) 이벤트 |
관련 콘솔 변수 (5.8 기준 기본값):
콘솔 변수 기본값 의미
| r.Visibility.TaskSchedule | 1 | 1=가시성 태스크를 병렬 태스크 그래프에서, 0=렌더 스레드 중심 |
| r.Visibility.FrustumCull.NumPrimitivesPerTask | 0(자동) | 프러스텀 컬 태스크당 프리미티브 수 고정 |
| r.Visibility.FrustumCull.UseOctree | 0 | 옥트리 노드 단위 프러스텀 컬링(실험적) |
| r.GPUScene.ParallelUpdate | 2048 | 이 개수를 넘으면 GPU Scene 업로드 패킹 병렬화 |
| r.GPUScene.UploadEveryFrame | 0 | 매 프레임 전체 재업로드(디버그용) |
| r.CullInstances | 1 | GPU 인스턴스 컬링 on/off |
| r.InstanceCulling.OcclusionCull | 0 | 인스턴스 단위 HZB 오클루전 컬링(프리뷰) |
진단 흐름 예시: 렌더 스레드가 높다 → Insights에서 SceneVisibility_FrustumCull이 크면 프리미티브 수 문제(원칙 1·2), ComputeViewRelevance가 크면 생존자 문제(원칙 4·5), GatherDynamicMeshElements가 크면 다이내믹 문제(원칙 3), UpdateGPUScene이 크면 dirty/화질 전환 문제(원칙 3·6).
11. 마무리
GPU 컬링은 "드로우" 비용의 문제를 GPU로 넘겼을 뿐, CPU가 매 프레임 수집하는 정보량을 없애지 않았다. UE 5.8 소스에서 확인한 대로:
- 프러스텀 컬은 씬의 모든 프리미티브를 뷰마다 순회한다.
- relevance와 드로우 커맨드 연결은 컬링 생존자 수에 비례한다.
- 다이내믹 프리미티브와 무버블은 매 프레임 재수집·재패킹된다.
- GPU 인스턴스 컬링조차 CPU 피드는 드로우 커맨드 수만큼 발생한다.
따라서 레벨 디자인의 균형은 결국 "동시에 존재하는 프리미티브 수를 관리하고(병합·스트리밍), 컬링 생존자를 일찍 줄이며(컬 거리·MinScreenSize·HLOD), 매 프레임 수집 대상을 최소화하는 것(Static 모빌리티)"으로 귀결된다. 엔진 코드를 읽으면 이런 결정들이 왜 가이드라인이 됐는지가 보인다 — 그리고 다음에 렌더 스레드 스파이크를 만났을 때, 어느 루프를 의심해야 하는지도 꼭 다시 점검할 것.
'UNREAL ENGINE' 카테고리의 다른 글
| [Update] UE6 Build Automation System. (0) | 2026.10.05 |
|---|---|
| UE5 GPU 프러스텀 컬링 동작 (0) | 2026.09.29 |
| [MooaToon] 6화.머티리얼 레이어로 커스텀 스타일 쌓기: MLB_ToonBaseBlend (11) | 2026.09.28 |
| [MooaToon] 5-3화. 아웃라인 (0) | 2026.09.27 |
| [MooaToon] 5-2화 — 림라이트: 화면 공간 깊이 테스트 (0) | 2026.09.27 |