작성자: gengar / 야생 갸루마 게임 그래픽 개발
학창 시절부터 실무까지 Unity 엔진을 주로 사용해왔기에 언리얼 엔진(UE)에 대한 경험은 아직 부족합니다. 오류나 부적절한 설명이 있다면 양해 부탁드립니다. 본문에서 사용된 UE 버전은 5.5.4입니다.
UE5부터는 완전한 현대적 Bindless API(DX12, Vulkan, Metal)를 지원하기 시작했습니다. UE는 Bindless 기능을 BindlessResource와 BindlessSampler로 나누었으며, 이 기능을 활성화하려면 DefaultEngine.ini를 수정해야 합니다.

UE5의 Bindless는 사용자에게 노출되는 Resource Collection (Explicit Bindless)과 엔진 계층에서 Bindless 특성을 지원하기 위해 수행된 셰이더 컴파일, 리소스 바인딩, RHI(Render Hardware Interface) 어댑테이션 등의 일련의 개조(Implicit Bindless)를 포함합니다.
Explicit Bindless (명시적 바인드리스)
UE는 상위 계층에 ResourceCollection을 제공하며, 그 중 TextureCollection은 일종의 UDataAsset입니다. 동시에 하위 계층에서는 ResourceCollection을 일종의 ShaderParameter로 취급합니다.

TextureCollection 에셋을 생성하고, 여기에 여러 Texture를 포함시킬 수 있습니다.

그 후 머티리얼 블루프린트에서 전용 노드를 사용하여 이를 활용할 수 있습니다.

TextureCollection은 하위 계층에서 실제로 하나의 리소스이며, 자체적인 버퍼(buffer)와 뷰(view)를 포함합니다. RHI 리소스를 초기화할 때 RHI 계층의 CreateResourceCollection을 호출하게 됩니다.

DX12를 예로 들면, CreateResourceCollection 내부에서는 실제로 버퍼 생성과 뷰 생성을 수행합니다.

버퍼에 저장되는 내용은 FResourceCollectionUpload에서 옵니다.

실제 버퍼 내용을 채우는 함수를 보면, 버퍼 내용이 count + 각 리소스의 고유한 bindless index로 구성됨을 알 수 있습니다.

버퍼 내용은 대략 아래와 같습니다.

셰이더에서는 접근을 위해 일련의 TEXTURE_FROM_COLLECTION 매크로를 정의합니다.

먼저 FResourceCollection에서 ResourceIndex를 가져온 다음, GetResourceFromHeap을 통해 인덱스를 사용하여 실제 리소스를 가져옵니다.

머티리얼의 PreviewShader (SM6 Epic Quality)를 보면 텍스처 접근 시 TextureFromCollection 함수를 사용하는 것을 볼 수 있습니다. 이는 앞서 설명한 대로 FResourceCollection에서 ResourceIndex를 얻고, GetResourceFromHeap을 통해 실제 리소스를 가져오는 방식입니다.


Implicit Bindless (암시적 바인드리스)
Implicit Bindless를 위한 일련의 개조 작업에는 ShaderCode 개조와 리소스 바인딩 개조가 포함됩니다.
리소스 바인딩
리소스 바인딩부터 살펴보면, UE C++ 셰이더에서는 SHADER_USE_ROOT_PARAMETER_STRUCT를 사용하여 루트 파라미터(Root Parameter)(즉, 셰이더 측에서 사용하는 리소스의 레이아웃)를 선언합니다. 동시에 Parameter 매크로를 사용하여 파라미터를 선언하는데, 이 매크로는 파라미터를 Metadata의 Members에 넣습니다.

SHADER_USE_ROOT_PARAMETER_STRUCT는 FShaderParameterBindings::BindForRootShaderParameters를 호출합니다. 이곳은 CPU 측에서 셰이더에 필요한 리소스의 레이아웃을 기록하는 곳으로, Bindless를 처리하고 BindlessParameter의 오프셋과 globalconstantoffset (bindless handle index)을 기록합니다.

SetParameter 시, Bindless 파라미터에 대해서는 BindlessParameter에 추가됩니다.


RHISetParameter 시(DX12 예시), Bindless의 경우 SetTexture 등은 단순히 큐에 뷰를 기록할 뿐입니다(이는 draw 전에 FD3D12StateCache::ApplyBindlessResources에서 리소스 상태 전이(Resource State Transition)를 수행하기 위함입니다). 또한 여기서 모든 ShaderParameterType이 Texture를 제외하고 자신만의 GetBindlessHandle을 구현하고 있음을 볼 수 있습니다. Texture는 DefaultBindlessHandle을 가져옵니다.

SetTexture가 Bindless를 설정할 때는 실제로는 해당 SRV(Shader Resource View)를 가져옵니다.

각 BindlessHandle에 대해, HandleIndex를 상수 버퍼(constant buffer)에 바인딩하는 SetBindlessHandle도 수행합니다 (이는 아래 셰이더 코드 개조의 BindlessSRV_xxx 등에 대응됩니다).

리소스를 힙(Heap)에 등록
뷰가 처음 생성될 때 디스크립터(Descriptor)를 힙(Heap)에 초기화하고, 뷰가 업데이트될 때 디스크립터도 함께 업데이트합니다.

ShaderCode 개조
Bindless 지원 하에서, 셰이더 내의 SRV/UAV 등의 접근은 uniform index + 전용 Get 함수 형태로 수정됩니다.

CompileD3DShader를 예로 들면, 흐름은 다음과 같습니다. 주로 GenerateBindless에서 처리됩니다(Bindless 사용 여부를 판단한 후 GenerateBindless로 진입).

ParseParameters에서 Bindless를 식별하고 Parameter의 BindlessConversionType을 설정합니다.


상수 버퍼에 bindless index를 추가합니다 (위의 BindlessSRV_xxx와 같음).

리소스 전용 접근 함수를 추가합니다. GenerateBindlessAccess가 있는 이유는 API마다 Bindless Shader 작성법이 다르기 때문입니다.

예를 들어 DX12에서는 Dynamic Resource 형식을 사용합니다.

참고로, UE와 Unity 모두 셰이더 컴파일을 별도의 프로세스로 실행하므로, 셰이더 컴파일 흐름을 디버깅하려면 VS(Visual Studio)에서 해당 프로세스에 어태치(Attach)하면 됩니다.

RHI Bindless
여기서는 UE의 RHI 계층에서 Bindless 메커니즘이 어떻게 캡슐화되어 있는지 간단히 살펴봅니다.
DX12
Shader Model 6.6의 Dynamic Resource를 사용하며, BindlessManager가 주요 기능 클래스입니다.

FD3D12BindlessDescriptorManager::Init에서 먼저 Bindless 지원 여부를 확인합니다. Bindless 지원 여부는 현재 플랫폼의 DataDrivenShaderPlatformInfo에서 가져옵니다.


예를 들어 안드로이드 설정은 이 경로에 있습니다 (스크린샷은 오래된 것이라 버전은 무시하세요).

BindlessResource와 BindlessSampler의 활성화 여부는 Config에서 읽어옵니다. Resource와 Sampler는 각각 자체 관리자(Manager)를 가집니다.
void FD3D12BindlessDescriptorManager::Init()
{
ResourcesConfiguration = RHIGetRuntimeBindlessResourcesConfiguration(GMaxRHIShaderPlatform);
SamplersConfiguration = RHIGetRuntimeBindlessSamplersConfiguration(GMaxRHIShaderPlatform);
if (ResourcesConfiguration != ERHIBindlessConfiguration::Disabled)
{
const TStatId Stats[] =
{
GET_STATID(STAT_ResourceDescriptorsAllocated),
GET_STATID(STAT_BindlessResourceDescriptorsAllocated),
};
uint32 NumResourceDescriptors = GBindlessResourceDescriptorHeapSize;
#if D3D12RHI_USE_CONSTANT_BUFFER_VIEWS
NumResourceDescriptors += GBindlessOnlineDescriptorHeapBlockSize;
#endif
ResourceManager = MakeUnique<FD3D12BindlessResourceManager>(GetParentDevice(), NumResourceDescriptors, Stats);
}
if (SamplersConfiguration != ERHIBindlessConfiguration::Disabled)
{
const TStatId Stats[] =
{
GET_STATID(STAT_SamplerDescriptorsAllocated),
GET_STATID(STAT_BindlessSamplerDescriptorsAllocated),
};
uint32 NumSamplerDescriptors = GBindlessSamplerDescriptorHeapSize;
if (NumSamplerDescriptors > D3D12_MAX_SHADER_VISIBLE_SAMPLER_HEAP_SIZE)
{
UE_LOG(LogD3D12RHI, Error, TEXT("D3D12.Bindless.SamplerDescriptorHeapSize was set to %d, which is higher than the D3D12 maximum of %d. Adjusting the value to prevent a crash."),
NumSamplerDescriptors,
D3D12_MAX_SHADER_VISIBLE_SAMPLER_HEAP_SIZE
);
NumSamplerDescriptors = D3D12_MAX_SHADER_VISIBLE_SAMPLER_HEAP_SIZE;
}
SamplerManager = MakeUnique<FD3D12BindlessSamplerManager>(GetParentDevice(), NumSamplerDescriptors, Stats);
}
}
BindlessResourceManager는 생성자에서 CPU Descriptor Heap (GPU Visible 플래그 없음)과 Allocator를 생성합니다. Heap의 Descriptor 수는 전역 GBindlessResourceDescriptorHeapSize (기본값 1000*1000)입니다.
FD3D12BindlessResourceManager::FD3D12BindlessResourceManager(FD3D12Device* InDevice, uint32 InNumDescriptors, TConstArrayView<TStatId> InStats)
: FD3D12DeviceChild(InDevice)
, CpuHeap(UE::D3D12BindlessDescriptors::CreateCpuHeap(InDevice, ERHIDescriptorHeapType::Standard, InNumDescriptors))
, Allocator(ERHIDescriptorHeapType::Standard, InNumDescriptors, InStats)
{
}
FD3D12DescriptorHeap* UE::D3D12BindlessDescriptors::CreateCpuHeap(FD3D12Device* InDevice, ERHIDescriptorHeapType InType, uint32 InNewNumDescriptorsPerHeap)
{
const TCHAR* const HeapName = (InType == ERHIDescriptorHeapType::Standard) ? TEXT("BindlessResourcesCPU") : TEXT("BindlessSamplersCPU");
return InDevice->GetDescriptorHeapManager().AllocateIndependentHeap(
HeapName,
InType,
InNewNumDescriptorsPerHeap,
ED3D12DescriptorHeapFlags::None
);
}
enum class ED3D12DescriptorHeapFlags : uint8
{
None = 0,
GpuVisible = 1 << 0,
Poolable = 1 << 1,
};
ENUM_CLASS_FLAGS(ED3D12DescriptorHeapFlags)
FD3D12BindlessDescriptorManager::CleanupResources는 주로 Heap 리소스를 해제합니다.
void FD3D12BindlessDescriptorManager::CleanupResources()
{
if (ResourceManager)
{
ResourceManager->CleanupResources();
}
if (SamplerManager)
{
SamplerManager->CleanupResources();
}
}
Allocate & Free: 디스크립터를 하나 할당합니다. Resource는 직접 Allocate하고, Sampler는 Allocate 후 Initialize가 필요합니다.
FRHIDescriptorHandle FD3D12BindlessDescriptorManager::AllocateResourceHandle()
{
if (ResourceManager)
{
// Resource 할당은 단순히 빈 Index 하나를 할당합니다.
return ResourceManager->Allocate();
}
return FRHIDescriptorHandle();
}
FRHIDescriptorHandle FD3D12BindlessDescriptorManager::AllocateAndInitialize(FD3D12SamplerState* SamplerState)
{
if (SamplerManager)
{
return SamplerManager->AllocateAndInitialize(SamplerState);
}
return FRHIDescriptorHandle();
}
// Sampler는 Index를 할당함과 동시에, 전달받은 Sampler의 CPU Descriptor를 Sampler GPU Heap으로 복사합니다.
FRHIDescriptorHandle FD3D12BindlessSamplerManager::AllocateAndInitialize(FD3D12SamplerState* SamplerState)
{
FRHIDescriptorHandle Result = Allocator.Allocate();
if (ensure(Result.IsValid()))
{
UE::D3D12Descriptors::CopyDescriptor(GetParentDevice(), GpuHeap, Result, SamplerState->OfflineDescriptor);
}
check(Result.IsValid());
return Result;
}
// 해제는 Allocator를 통해 해제된 것으로 표시합니다.
void FD3D12BindlessDescriptorManager::ImmediateFree(FRHIDescriptorHandle InHandle)
{
if (InHandle.GetType() == ERHIDescriptorHeapType::Standard && ResourceManager)
{
ResourceManager->Free(InHandle);
return;
}
if (InHandle.GetType() == ERHIDescriptorHeapType::Sampler && SamplerManager)
{
SamplerManager->Free(InHandle);
return;
}
// 잘못된 설정?
checkNoEntry();
}
void FD3D12BindlessDescriptorManager::FinalizeContext(FD3D12CommandContext& Context)
{
if (ResourceManager)
{
ResourceManager->FinalizeContext(Context);
}
}
void FD3D12BindlessResourceManager::FinalizeContext(FD3D12CommandContext& Context)
{
if (Context.IsOpen())
{
Context.CloseCommandList();
}
FD3D12ContextBindlessState& State = Context.GetBindlessState();
if (State.UsedHeaps.Num() > 0)
{
for (const FD3D12DescriptorHeapPtr& UsedHeap : State.UsedHeaps)
{
checkSlow(UsedHeap);
// 이제 삭제 대기열에 넣습니다.
UE::D3D12BindlessDescriptors::DeferredFreeHeap(GetParentDevice(), UsedHeap);
}
State.UsedHeaps.Empty();
}
check(!Context.GetBindlessState().HasAnyPending());
}
UpdateDescriptor: 디스크립터를 힙에 업데이트합니다.
void FD3D12BindlessDescriptorManager::UpdateDescriptorImmediately(FRHIDescriptorHandle DstHandle, FD3D12View* View)
{
if (DstHandle.GetType() == ERHIDescriptorHeapType::Standard && ResourceManager)
{
ResourceManager->UpdateDescriptorImmediately(DstHandle, View);
return;
}
// 잘못된 설정?
checkNoEntry();
}
// 즉시 업데이트: 디스크립터를 Heap으로 직접 복사합니다.
void FD3D12BindlessResourceManager::UpdateDescriptorImmediately(FRHIDescriptorHandle DstHandle, FD3D12View* View)
{
if (DstHandle.IsValid())
{
UE::D3D12Descriptors::CopyDescriptor(GetParentDevice(), CpuHeap, DstHandle, View->GetOfflineCpuHandle());
}
}
void FD3D12BindlessDescriptorManager::UpdateDescriptor(FRHICommandListBase& RHICmdList, FRHIDescriptorHandle DstHandle, FD3D12View* View)
{
if (ResourceManager)
{
ResourceManager->UpdateDescriptor(RHICmdList, DstHandle, View);
return;
}
// 잘못된 설정?
checkNoEntry();
}
void FD3D12BindlessResourceManager::UpdateDescriptor(FRHICommandListBase& RHICmdList, FRHIDescriptorHandle DstHandle, FD3D12View* View)
{
// 유효한 Index가 필요합니다.
if (DstHandle.IsValid())
{
for (ERHIPipeline PipelineIndex : GetRHIPipelines())
{
FRHICommandListScopedPipeline Scope(RHICmdList, PipelineIndex);
RHICmdList.EnqueueLambda([this, View, DstHandle](FRHICommandListBase& ExecutingCmdList)
{
FD3D12CommandContext& Context =
ExecutingCmdList.IsGraphics()
? static_cast<FD3D12CommandContext&>(ExecutingCmdList.GetContext())
: static_cast<FD3D12CommandContext&>(ExecutingCmdList.GetComputeContext());
if (Context.IsOpen())
{
// 업데이트할 위치의 현재 디스크립터 스냅샷을 생성하여 OfflineDescriptorManager에 저장
FD3D12OfflineDescriptor CopyOfPreviousDescriptorValue = UE::D3D12Descriptors::CreateOfflineCopy(GetParentDevice(), CpuHeap, DstHandle);
// 롤백 리스트에 추가
Context.GetBindlessState().PendingDescriptorRollbacks.Add(GetParentDevice(), DstHandle, CopyOfPreviousDescriptorValue);
}
});
}
RHICmdList.EnqueueLambda([this, DstHandle, View](FRHICommandListBase& ExecutingCmdList)
{
// 실제 디스크립터 업데이트
UpdateDescriptorImmediately(DstHandle, View);
});
}
}
// CreateOfflineCopy
FD3D12OfflineDescriptor UE::D3D12Descriptors::CreateOfflineCopy(FD3D12Device* Device, D3D12_CPU_DESCRIPTOR_HANDLE InDescriptor, ERHIDescriptorHeapType InType)
{
FD3D12OfflineDescriptor NewDescriptor = Device->GetOfflineDescriptorManager(InType).AllocateHeapSlot();
Device->GetDevice()->CopyDescriptorsSimple(1, NewDescriptor, InDescriptor, Translate(InType));
NewDescriptor.IncrementVersion();
return NewDescriptor;
}
FD3D12OfflineDescriptor UE::D3D12Descriptors::CreateOfflineCopy(FD3D12Device* Device, FD3D12DescriptorHeap* InHeap, FRHIDescriptorHandle InHandle)
{
D3D12_CPU_DESCRIPTOR_HANDLE SourceDescriptor = InHeap->GetCPUSlotHandle(InHandle.GetIndex());
return UE::D3D12Descriptors::CreateOfflineCopy(Device, SourceDescriptor, InHeap->GetType());
}
FlushPendingDescriptorUpdates: PendingDescriptorRollbacks가 존재할 때만 힙 전환을 트리거합니다. 상태 일관성: 회귀(Rollback) 메커니즘을 통해 GPU 힙 상태와 CPU 힙이 최종적으로 일치하도록 보장합니다. 매 프레임 새로운 힙을 생성하는 것을 피하고 필요할 때만 재구축합니다.
void FD3D12BindlessDescriptorManager::FlushPendingDescriptorUpdates(FD3D12CommandContext& Context)
{
if (ResourceManager)
{
ResourceManager->FlushPendingDescriptorUpdates(Context);
}
}
void FD3D12BindlessResourceManager::FlushPendingDescriptorUpdates(FD3D12CommandContext& Context)
{
FD3D12ContextBindlessState& State = Context.GetBindlessState();
if (State.PendingDescriptorRollbacks.Num() > 0)
{
// 롤백할 내용이 있다면 새로운 힙으로 이동해야 합니다.
// 먼저 이전에 설정된 힙이 있다면 마무리합니다.
FinalizeHeapOnState(State);
// 그리고 사용할 새 힙을 생성합니다.
CreateHeapOnState(State);
if (ensure(Context.IsOpen()))
{
// 마지막으로 Context에 이 힙을 사용한다고 알립니다.
// 이 호출은 또한 d3d command list에 힙이 설정되도록 합니다.
Context.StateCache.GetDescriptorCache()->SwitchToNewBindlessResourceHeap(State.CurrentGpuHeap);
}
}
}
CommandList가 제공하는 작업들:
void FD3D12BindlessDescriptorManager::OpenCommandList(FD3D12CommandContext& Context)
{
if (ResourceManager)
{
ResourceManager->OpenCommandList(Context);
}
if (SamplerManager)
{
SamplerManager->OpenCommandList(Context);
}
}
void FD3D12BindlessDescriptorManager::CloseCommandList(FD3D12CommandContext& Context)
{
if (ResourceManager)
{
ResourceManager->CloseCommandList(Context);
}
if (SamplerManager)
{
SamplerManager->CloseCommandList(Context);
}
}
void FD3D12BindlessResourceManager::OpenCommandList(FD3D12CommandContext& Context)
{
FD3D12ContextBindlessState& State = Context.GetBindlessState();
// 항상 사용할 새 디스크립터 힙을 생성합니다.
// TODO: 첫 번째 FlushPendingDescriptorUpdates까지 지연시킬 수 있음
CreateHeapOnState(State);
// 디스크립터 캐시에 힙을 할당합니다.
Context.StateCache.GetDescriptorCache()->SetBindlessResourcesHeapDirectly(State.CurrentGpuHeap);
}
void FD3D12BindlessResourceManager::CloseCommandList(FD3D12CommandContext& Context)
{
FD3D12ContextBindlessState& State = Context.GetBindlessState();
// 현재 설정된 힙이 있다면 마무리합니다.
FinalizeHeapOnState(State);
// 상태 캐시에서 참조를 지웁니다.
Context.StateCache.GetDescriptorCache()->SetBindlessResourcesHeapDirectly(nullptr);
}
void FD3D12BindlessSamplerManager::OpenCommandList(FD3D12CommandContext& Context)
{
Context.StateCache.GetDescriptorCache()->SetBindlessSamplersHeapDirectly(GetHeap());
}
void FD3D12BindlessSamplerManager::CloseCommandList(FD3D12CommandContext& Context)
{
Context.StateCache.GetDescriptorCache()->SetBindlessSamplersHeapDirectly(nullptr);
}
FD3D12BindlessDescriptorAllocator 클래스는 디스크립터 할당에 사용됩니다.

Init: Config 설정에 따라 Allocator를 생성합니다.
void FD3D12BindlessDescriptorAllocator::Init()
{
LLM_SCOPE_BYNAME(TEXT("RHIMisc/BindlessDescriptorAllocator"));
BindlessResourcesConfiguration = RHIGetRuntimeBindlessResourcesConfiguration(GMaxRHIShaderPlatform);
BindlessSamplersConfiguration = RHIGetRuntimeBindlessSamplersConfiguration(GMaxRHIShaderPlatform);
if (BindlessResourcesConfiguration != ERHIBindlessConfiguration::Disabled)
{
const TStatId Stats[] =
{
GET_STATID(STAT_ResourceDescriptorsAllocated),
GET_STATID(STAT_BindlessResourceDescriptorsAllocated),
};
uint32 NumResourceDescriptors = GBindlessResourceDescriptorHeapSize;
#if D3D12RHI_USE_CONSTANT_BUFFER_VIEWS
NumResourceDescriptors += GBindlessOnlineDescriptorHeapBlockSize;
#endif
ResourceAllocator = new FRHIHeapDescriptorAllocator(ERHIDescriptorHeapType::Standard, NumResourceDescriptors, Stats);
}
if (BindlessSamplersConfiguration != ERHIBindlessConfiguration::Disabled)
{
const TStatId Stats[] =
{
GET_STATID(STAT_SamplerDescriptorsAllocated),
GET_STATID(STAT_BindlessSamplerDescriptorsAllocated),
};
uint32 NumSamplerDescriptors = GBindlessSamplerDescriptorHeapSize;
if (NumSamplerDescriptors > D3D12_MAX_SHADER_VISIBLE_SAMPLER_HEAP_SIZE)
{
UE_LOG(LogD3D12RHI, Error, TEXT("D3D12.Bindless.SamplerDescriptorHeapSize was set to %d, which is higher than the D3D12 maximum of %d. Adjusting the value to prevent a crash."),
NumSamplerDescriptors,
D3D12_MAX_SHADER_VISIBLE_SAMPLER_HEAP_SIZE
);
NumSamplerDescriptors = D3D12_MAX_SHADER_VISIBLE_SAMPLER_HEAP_SIZE;
}
SamplerAllocator = new FRHIHeapDescriptorAllocator(ERHIDescriptorHeapType::Sampler, NumSamplerDescriptors, Stats);
}
}
Allocate & Free Resource:
FRHIDescriptorHandle FD3D12BindlessDescriptorAllocator::AllocateResourceHandle()
{
if (!AreResourcesBindless())
{
return FRHIDescriptorHandle();
}
FRHIDescriptorHandle Result = ResourceAllocator->Allocate();
#if D3D12RHI_BINDLESS_RESOURCE_MANAGER_SUPPORTS_RESIZING
if (!Result.IsValid())
{
TRACE_CPUPROFILER_EVENT_SCOPE(FD3D12Adapter::BindlessResourceAllocateHandle(GrowHeap));
FScopeLock ScopeLock(&ResourceHeapsCS);
// 디스크립터 핸들 할당기 확장
uint32 CurrentNumDescriptors = ResourceAllocator->GetCapacity();
uint32 NewNumDescriptors = CurrentNumDescriptors * 2;
Result = ResourceAllocator->ResizeGrowAndAllocate(NewNumDescriptors, ResourceAllocator->GetType());
// 모든 디바이스의 CPU 힙 확장
for (FD3D12Device* ParentDevice : GetParentAdapter()->GetDevices())
{
checkSlow(ParentDevice->GetBindlessDescriptorManager().GetResourceManager() != nullptr);
ParentDevice->GetBindlessDescriptorManager().GetResourceManager()->GrowCPUHeap(CurrentNumDescriptors, NewNumDescriptors);
}
return Result;
}
#endif
check(Result.IsValid());
return Result;
}
// Free는 단순히 마킹만 합니다.
void FD3D12BindlessDescriptorAllocator::FreeResourceHandle(FRHIDescriptorHandle InHandle)
{
if (InHandle.IsValid())
{
ResourceAllocator->Free(InHandle);
}
}
Allocate & Free Sampler:
FRHIDescriptorHandle FD3D12BindlessDescriptorAllocator::AllocateSamplerHandle()
{
FRHIDescriptorHandle Result = SamplerAllocator->Allocate();
check(Result.IsValid());
return Result;
}
void FD3D12BindlessDescriptorAllocator::FreeSamplerHandle(FRHIDescriptorHandle InHandle)
{
if (InHandle.IsValid())
{
SamplerAllocator->Free(InHandle);
}
}
Usage: 리소스에 대해 bindlessHandle을 생성합니다 (Texture 예시).
FD3D12RHITextureReference::FD3D12RHITextureReference(FD3D12Device* InDevice, FD3D12Texture* InReferencedTexture, FD3D12RHITextureReference* FirstLinkedObject)
: FD3D12DeviceChild(InDevice)
#if PLATFORM_SUPPORTS_BINDLESS_RENDERING
// Handle 할당
, FRHITextureReference(InReferencedTexture, FirstLinkedObject ? FirstLinkedObject->BindlessHandle : InDevice->GetBindlessDescriptorAllocator().AllocateResourceHandle())
#else
, FRHITextureReference(InReferencedTexture)
#endif
{
#if PLATFORM_SUPPORTS_BINDLESS_RENDERING
if (BindlessHandle.IsValid())
{
InReferencedTexture->AddRenameListener(this);
FD3D12ShaderResourceView* View = InReferencedTexture->GetShaderResourceView();
ReferencedDescriptorVersion = View->GetOfflineCpuHandle().GetVersion();
// 디스크립터 초기화: 지정된 리소스 뷰(FD3D12View* View)를 목표 디스크립터 핸들(FRHIDescriptorHandle DstHandle) 위치로 복사
InDevice->GetBindlessDescriptorManager().InitializeDescriptor(BindlessHandle, View);
}
#endif // PLATFORM_SUPPORTS_BINDLESS_RENDERING
}
void FD3D12BindlessResourceManager::InitializeDescriptor(FRHIDescriptorHandle DstHandle, FD3D12View* View)
{
if (DstHandle.IsValid())
{
TRACE_CPUPROFILER_EVENT_SCOPE(FD3D12BindlessResourceManager::InitializeDescriptor);
// 1. 스레드 안전 잠금 (멀티스레드 환경 보호)
FScopeLock ScopeLock(&HeapsCS);
// CPU와 활성 GPU 힙 모두 업데이트 (초기화 단계이며 핸들이 GPU에서 사용 중이 아님을 알기 때문)
// 2. 소스 뷰의 CPU 디스크립터 가져오기
FD3D12OfflineDescriptor OfflineCpuHandle = View->GetOfflineCpuHandle();
// 3. CPU 디스크립터 힙에 복사
UE::D3D12Descriptors::CopyDescriptor(GetParentDevice(), CpuHeap, DstHandle, OfflineCpuHandle);
// 활성 GPU 힙과 더티 리스트에 디스크립터 복사 (RHI 스레드에서 활성 GPU 힙이 변경될 수 있으므로 잠금 필요)
// 4. 현재 활성 GPU 디스크립터 힙에 복사
if (ActiveGpuHeapIndex >= 0 && !bCPUHeapResized)
{
UE::D3D12Descriptors::CopyDescriptor(GetParentDevice(), ActiveGpuHeaps[ActiveGpuHeapIndex].GpuHeap, DstHandle, OfflineCpuHandle);
// 5. 업데이트된 것으로 표시
ActiveGpuHeaps[ActiveGpuHeapIndex].UpdatedHandles.Add(DstHandle);
}
INC_DWORD_STAT(STAT_D3D12BindlessResourceDescriptorsInitialized);
}
}
// 소멸 시 리소스 해제
FD3D12RHITextureReference::~FD3D12RHITextureReference()
{
#if PLATFORM_SUPPORTS_BINDLESS_RENDERING
if (BindlessHandle.IsValid())
{
FD3D12DynamicRHI::ResourceCast(GetReferencedTexture())->RemoveRenameListener(this);
// Bindless 핸들은 공유되며, 헤드 링크 객체가 해제를 처리함
if (IsHeadLink())
{
GetParentDevice()->GetBindlessDescriptorManager().DeferredFreeFromDestructor(BindlessHandle);
}
}
#endif // PLATFORM_SUPPORTS_BINDLESS_RENDERING
}
DynamicResource 기능 덕분에 셰이더 코드가 매우 간결합니다.

Vulkan
Manager와 DeviceChild는 실제로 Device 포인터를 보유하는 것입니다.

VerifySupport: Manager 생성자에서 호환성을 확인합니다. UE에서는 VK_EXT_descriptor_indexing, VK_KHR_buffer_device_address, VK_EXT_descriptor_buffer 이 세 가지 확장이 모두 필요합니다.
// Check all the requirements to be running in Bindless using Descriptor Buffers
bool FVulkanBindlessDescriptorManager::VerifySupport(FVulkanDevice* Device)
{
const bool bFullyDisabled =
(RHIGetRuntimeBindlessResourcesConfiguration(GMaxRHIShaderPlatform) == ERHIBindlessConfiguration::Disabled) &&
(RHIGetRuntimeBindlessSamplersConfiguration(GMaxRHIShaderPlatform) == ERHIBindlessConfiguration::Disabled);
if (bFullyDisabled)
{
return false;
}
const bool bFullyEnabled =
(RHIGetRuntimeBindlessResourcesConfiguration(GMaxRHIShaderPlatform) == ERHIBindlessConfiguration::AllShaders) &&
(RHIGetRuntimeBindlessSamplersConfiguration(GMaxRHIShaderPlatform) == ERHIBindlessConfiguration::AllShaders);
if (bFullyEnabled)
{
const VkPhysicalDeviceProperties& GpuProps = Device->GetDeviceProperties();
const FOptionalVulkanDeviceExtensions& OptionalDeviceExtensions = Device->GetOptionalExtensions();
const VkPhysicalDeviceDescriptorBufferPropertiesEXT& DescriptorBufferProperties = Device->GetOptionalExtensionProperties().DescriptorBufferProps;
const bool bMeetsExtensionsRequirements =
OptionalDeviceExtensions.HasEXTDescriptorIndexing &&
OptionalDeviceExtensions.HasBufferDeviceAddress &&
OptionalDeviceExtensions.HasEXTDescriptorBuffer;
if (bMeetsExtensionsRequirements)
{
const bool bMeetsPropertiesRequirements =
(GpuProps.limits.maxBoundDescriptorSets >= VulkanBindless::MaxNumSets) &&
(DescriptorBufferProperties.maxDescriptorBufferBindings >= VulkanBindless::MaxNumSets) &&
// UE에서는 각 유형별로 BindlessSet을 준비합니다. 아래 코드 참조
(DescriptorBufferProperties.maxResourceDescriptorBufferBindings >= VulkanBindless::NumBindlessSets) &&
(DescriptorBufferProperties.maxSamplerDescriptorBufferBindings >= 1) &&
Device->GetDeviceMemoryManager().SupportsMemoryType(GetDescriptorBufferMemoryType(Device));
if (bMeetsPropertiesRequirements)
{
extern TAutoConsoleVariable<int32> GDynamicGlobalUBs;
if (GDynamicGlobalUBs->GetInt() != 0)
{
UE_LOG(LogRHI, Warning, TEXT("Dynamic Uniform Buffers are enabled, but they will not be used with Vulkan bindless."));
}
extern int32 GVulkanEnableDefrag;
if (GVulkanEnableDefrag != 0) // :todo-jn: to be turned back on with new defragger
{
UE_LOG(LogRHI, Warning, TEXT("Memory defrag is enabled, but it will not be used with Vulkan bindless."));
GVulkanEnableDefrag = 0;
}
return true;
}
else
{
UE_LOG(LogRHI, Warning, TEXT("Bindless descriptor were requested but NOT enabled because of insufficient property support."));
}
}
else
{
UE_LOG(LogRHI, Warning, TEXT("Bindless descriptor were requested but NOT enabled because of missing extension support."));
}
}
else
{
UE_LOG(LogRHI, Warning, TEXT("Bindless in Vulkan must currently be fully enabled (all samplers and resources) or fully disabled."));
}
return false;
}
namespace VulkanBindless
{
static constexpr uint32 MaxUniformBuffersPerStage = 16;
// ssbo, uniformBuffer, image, texetbuffer, accel 등은 별도의 Set입니다.
enum EDescriptorSets
{
BindlessSamplerSet = 0,
BindlessStorageBufferSet,
BindlessUniformBufferSet,
BindlessStorageImageSet,
BindlessSampledImageSet,
BindlessStorageTexelBufferSet,
BindlessUniformTexelBufferSet,
BindlessAccelerationStructureSet,
// Number of sets reserved for samplers/resources
NumBindlessSets,
// Index of the descriptor set used for single use ub (like globals)
BindlessSingleUseUniformBufferSet = NumBindlessSets,
// Total number of descriptor sets used in a bindless pipeline
// bindless pipeline 에서 사용되는 descriptor set 의 수량
MaxNumSets = NumBindlessSets + 1
};
};
각 확장의 지원 여부뿐만 아니라 필요한 Feature가 충족되는지도 확인해야 합니다.

Init: 각 유형의 descriptor는 자신의 descriptorBuffer와 layout 등을 보유한 state에 대응됩니다. void FVulkanBindlessDescriptorManager::Init()에서 생성을 완료합니다.
struct BindlessSetState
{
VkDescriptorType DescriptorType = VK_DESCRIPTOR_TYPE_MAX_ENUM;
uint32 MaxDescriptorCount = 0;
std::atomic<uint32> PeakDescriptorCount = 1; // always keep a null descriptor in slot 0
VkDescriptorSetLayout DescriptorSetLayout = VK_NULL_HANDLE;
FCriticalSection FreeListCS;
uint32 FreeListHead = MAX_uint32;
uint32 DescriptorSize = 0;
VkBuffer BufferHandle = VK_NULL_HANDLE;
VkDeviceMemory MemoryHandle = VK_NULL_HANDLE;
uint8* MappedPointer = nullptr;
TArray<uint8> DebugDescriptors;
};
BindlessSetState BindlessSetStates[VulkanBindless::NumBindlessSets];
BindlessManager가 외부에 제공하는 주요 인터페이스는 다음과 같습니다.
// CommandBufferBegin에서 호출되어, 모든 유형의 descriptorBuffer를 pipeline에 바인딩합니다.
void BindDescriptorBuffers(VkCommandBuffer CommandBuffer, VkPipelineStageFlags SupportedStages);
// 외부에서 handle을 하나 요청합니다. 여기에는 setIndex와 bindingIndex가 포함되며, bindless desc가 필요한 모든 곳은 이 인터페이스를 통해 요청해야 합니다.
// 예: VulkanUniformBuffer/VulkanUAV
// example: BindlessHandle = Device.GetBindlessDescriptorManager()->ReserveDescriptor(InDescriptorType);
FRHIDescriptorHandle ReserveDescriptor(VkDescriptorType DescriptorType);
// 아래 인터페이스들은 각 리소스 유형을 bindless set에 업데이트합니다.
void UpdateSampler(FRHIDescriptorHandle DescriptorHandle, VkSampler VulkanSampler);
void UpdateImage(FRHIDescriptorHandle DescriptorHandle, VkImageView VulkanImage, bool bIsDepthStencil, bool bImmediateUpdate = true);
void UpdateBuffer(FRHIDescriptorHandle DescriptorHandle, VkBuffer VulkanBuffer, VkDeviceSize BufferOffset, VkDeviceSize BufferSize, bool bImmediateUpdate = true);
void UpdateBuffer(FRHIDescriptorHandle DescriptorHandle, VkDeviceAddress BufferAddress, VkDeviceSize BufferSize, bool bImmediateUpdate = true);
void UpdateTexelBuffer(FRHIDescriptorHandle DescriptorHandle, const VkBufferViewCreateInfo& ViewInfo, bool bImmediateUpdate = true);
void UpdateAccelerationStructure(FRHIDescriptorHandle DescriptorHandle, VkAccelerationStructureKHR AccelerationStructure, bool bImmediateUpdate = true);
// 현재 stage에 필요한 모든 uniform buffer를 bindless에 일괄 등록하여, uniform buffer를 대량으로 전환할 때 사용합니다.
// FVulkanComputePipelineDescriptorState::UpdateBindlessDescriptors 에서 사용
void RegisterUniformBuffers(FVulkanCmdBuffer* CommandBuffer, VkPipelineBindPoint BindPoint, const FUniformBufferDescriptorArrays& StageUBs);
// 등록 해제
void Unregister(FRHIDescriptorHandle DescriptorHandle);
void UpdateUBAllocator();
Usage: uniformBuffer에 bindless 등록 및 리소스 업데이트

그리기(Draw) 전에 bindless 정보를 바인딩합니다.


셰이더에서는 Type[]을 선언하고 인덱스를 통해 접근합니다 (GetBindlessResource/GetBindlessSampler).

Metal
Resource와 Sampler 두 개의 DescriptorHeap을 초기화합니다.

리소스 바인딩:

BindSample, BindTexture 등은 파라미터를 Argument Buffer에 설정하는 것입니다.

UpdateDescriptor는 argument buffer의 내용을 업데이트합니다.
void FMetalBindlessDescriptorManager::UpdateDescriptorsWithGPU(FMetalRHICommandContext* Context)
{
#if USE_CPU_DESCRIPTOR_COPY
return;
#elif USE_DESCRIPTOR_BUFFER_COPY
UpdateDescriptorsWithCopy(Context);
#else
UpdateDescriptorsWithCompute();
#endif
}
void FMetalBindlessDescriptorManager::UpdateDescriptorsWithCopy(FMetalRHICommandContext* Context)
{
#if !USE_CPU_DESCRIPTOR_COPY
FScopeLock ScopeLock(&ComputeDescriptorCS);
if(!StandardResources.DescriptorsDirty)
{
return;
}
uint32_t IndexOffset = StandardResources.MinDirtyIndex;
uint32_t UpdateSize = ((StandardResources.MaxDirtyIndex - StandardResources.MinDirtyIndex) + 1) * sizeof(IRDescriptorTableEntry);
uint32_t UpdateOffset = StandardResources.MinDirtyIndex * sizeof(IRDescriptorTableEntry);
FMetalBufferPtr SourceBuffer = Device.GetTransferAllocator()->Allocate(UpdateSize);
StandardResources.DescriptorsDirty = false;
StandardResources.MinDirtyIndex = UINT32_MAX;
StandardResources.MaxDirtyIndex = 0;
FMetalRHIBuffer* DestBuffer = ResourceCast(StandardResources.ResourceHeap.GetReference());
IRDescriptorTableEntry* DescriptorCopy = (IRDescriptorTableEntry*)SourceBuffer->Contents();
#if USE_DESCRIPTOR_BUFFER_COPY
FMemory::Memcpy(DescriptorCopy, StandardResources.Descriptors + IndexOffset, UpdateSize);
#else
FMemory::Memcpy(DescriptorCopy, StandardResources.Descriptors + IndexOffset, StandardResources.ResourceHeapLength);
for(uint32_t Idx = 0; Idx < ComputeDescriptorEntriesCopy.Num(); ++Idx)
{
DescriptorCopy[ComputeDescriptorIndicesCopy[Idx]-IndexOffset] = ComputeDescriptorEntriesCopy[Idx];
}
#endif
Context->CopyFromBufferToBuffer(SourceBuffer, 0, DestBuffer->GetCurrentBuffer(), UpdateOffset, UpdateSize);
}
셰이더 작성법은 DX12와 동일하며, 이는 컴파일 프로세스에 의존하는 것으로 보입니다.

Reference
- Unreal Engine 5.1 RHI Documentation
- HLSL Dynamic Resources (SM 6.6)
- Vulkan Descriptor Indexing
- Metal Argument Buffers
- Bindless Rendering Overview
마치며
HLSL, GLSL, MSL에서 Bindless를 작성하는 방법은 서로 매우 다르고 차이가 큽니다. UE5에서는 ShaderConductor를 통해 이러한 변환을 수행합니다(HLSL -> DXIL, HLSL -> SPIRV, SPIRV -> SPIRV-CROSS -> MSL). 추후 셰이더 컴파일 과정에서 Bindless가 어떻게 처리되는지 살펴보고 해당 흐름의 코드를 익힐 계획입니다. 또한 Metal에 대해서도 아직 잘 모르기 때문에 Metal 부분도 보완할 예정입니다.
원문
(56 封私信 / 48 条消息) 浅谈Bindless(二):UE5中的Bindless - 知乎
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [번역] Unity에서 스킨드 메시의 GPU Driven 렌더링 구현 (0) | 2026.01.03 |
|---|---|
| [발표 번역] SIGGRAPH 2025 idTech8에서의 글로벌 일루미네이션 (0) | 2026.01.02 |
| [발표 번역] UnrealFest 2025: UE5 조명 시스템의 성능과 품질 균형 맞추기 (1) | 2025.12.31 |
| [발표 번역] MEGALIGHTS: STOCHASTIC DIRECT LIGHTING IN UNREAL ENGINE 5 (0) | 2025.12.30 |
| 게임 Coder 전용 모델 강화 학습 WIP (0) | 2025.12.30 |