-
목차
- 개요
- 사용법
- Direct3D 11
- 객체 생성
- 질의 방법
- 질의 결과 얻기
- Direct3D 12
- 객체 생성
- 질의 방법
- 질의 결과 얻기
- Direct3D 11
- GPU Timer
- Direct3D 11
- Direct3D 12
- Hardware Occlusion Culling
- Direct3D 11
- Direct3D 12
- 가시성 판단
- 오클루전 검사 렌더링
- 마치며
- Reference
개요
이 글에서는 Direct3D 11/12로 구현된 샘플을 통해 Query(이하 쿼리) 객체의 사용 방법과 예시를 살펴보도록 하겠습니다. 쿼리 객체는 Graphics API에서 GPU 정보를 질의하기 위해 제공하는 객체로 이를 통해 여러가지 GPU 정보를 얻을 수 있습니다. 이 글은 Direct3D만을 다루지만 Vulkan이나 Metal과 같은 다른 Graphics API에서도 GPU 정보 질의를 위한 방법을 제공하므로 해당 API에 맞는 방식을 찾는데 도움이 되길 바랍니다.
사용법
먼저 쿼리 객체의 사용법부터 알아보겠습니다. 쿼리 객체는 종류마다 조금씩 사용법이 다른 부분이 있는데 이 부분은 실제 사용 예시에서 다루도록 하고 이 장에서는 쿼리 객체의 생성 방법과 질의 방법, 마지막으로 질의 결과를 얻어오는 방법을 각 API 별로 알아보도록 하겠습니다.
Direct3D 11
객체 생성
Direct3D 11에서는 ID3D11Query 인터페이스를 통해 사용하며 ID3D11Device의 CreateQuery 함수를 통해 쿼리 객체를 생성합니다. 객체 생성 시에는 D3D11_QUERY_DESC 구조체를 통해 쿼리 객체의 종류를 설정할 수 있습니다.
D3D11_QUERY_DESC desc = { .Query = D3D11_QUERY_TIMESTAMP, /* 쿼리 객체의 종류를 설정 */ .MiscFlags = 0, /* 대체로 0 사용 */ }; D3D11Device().CreateQuery( &desc, &m_timeStampBegin );
생성할 수 있는 쿼리 객체의 종류가 무엇인지 전부 다루지 않으므로(사용 예시에서 몇 가지만 다룰 예정입니다.) 세부 내용이 궁금하시면 D3D11_QUERY 열거형에 대한 msdn 문서를 참고하시기 바랍니다.
질의 방법
쿼리 객체를 이용한 질의는 ID3D11DeviceContext의 Begin, End 함수를 통해 이뤄집니다. Begin 함를 통해 질의의 시작 위치를 정하고 End 함수를 통해 질의의 끝 위치를 정합니다. Begin 함수 와 End 함수는 구간에 대한 질의인 경우에는 쌍을 지어 호출해야 하지만 그렇지 않은 경우에는 End 함수만 호출합니다. 이에 대한 예시는 GPU Timer 부분에서 다룹니다.
D3D11Context().Begin( d3d11Query ); /* 질의 시작 */ D3D11Context().End( d3d11Query ); /* 질의 끝 */
질의 결과 얻기
질의 결과는 ID3D11DeviceContext의 GetData 함수를 통해 얻을 수 있습니다. 쿼리 객체의 종류에 따라 얻어올 데이터의 크기가 다르기 때문에 알맞은 자료형을 사용해야 합니다.
uint64 beginTime = 0; D3D11Context().GetData( m_timeStampBegin, &beginTime, sizeof( beginTime ), 0 );
여기서 한 가지 주의할 점은 암시적인 동기화 지점이 있는 Direct3D 11에서 GetData 함수는 비동기로 동작한다는 점입니다. 만약 GetData 함수를 호출하는 시점에서 GPU에 대한 질의가 완료되지 않았다면 S_FALSE를 반환합니다. 따라서 별도의 동기화 방법이 필요하며 가장 간단한 방법은 다음과 같이 S_OK를 반환할 때까지 GetData 함수를 반복적으로 호출하는 것입니다.
uint64 beginTime = 0; while ( D3D11Context().GetData( m_timeStampBegin, &beginTime, sizeof( beginTime ), 0 ) != S_OK );
Direct3D 12
객체 생성
Direct3D 12에서 쿼리 객체를 사용하기 위해서는 Query Heap(이하 쿼리 힙)을 할당할 필요가 있습니다. 쿼리 힙은 ID3D12QueryHeap 인터페이스를 사용하며 ID3D12Device의 CreateQueryHeap 함수를 통해서 할당할 수 있고 쿼리 객체의 종류에 따라 별도의 쿼리 힙을 할당해야 합니다.
D3D12_QUERY_HEAP_DESC heapDesc = { .Type = type, /* 쿼리 힙의 종류 */ .Count = m_freeSize, /* 쿼리 갯수 */ .NodeMask = 0 /* 다수의 GPU사용 시 설정, 하나의 GPU 사용시 0 */ }; HRESULT hr = D3D12Device().CreateQueryHeap( &heapDesc, IID_PPV_ARGS( m_heap.GetAddressOf() ) );
쿼리 힙의 종류는 D3D12_QUERY_HEAP_TYPE 열거형을 통해 정할 수 있으며 세부 내용에 대해서는 마찬가지로 msdn 문서를 참고 바랍니다.
이렇게 할당한 쿼리 힙에서 단일 Query 객체는 쿼리 힙에 대한 Index를 통해 사용하게 됩니다. 사용 예시는 이어지는 질의 방법 부분에서 살펴보겠습니다.
질의 방법
Direct3D 12에서의 질의는 ID3D12GraphicsCommandList의 BeginQuery, EndQuery 함수를 통해 이뤄집니다. 매개변수로 쿼리 힙에 대한 포인터, 쿼리의 종류, 쿼리 힙에 대한 Index를 전달 받습니다.
/* BeginQuery( 쿼리 힙에 대한 포인터, 쿼리 종류, 쿼리 힙에 대한 Index ) EndQuery( 쿼리 힙에 대한 포인터, 쿼리 종류, 쿼리 힙에 대한 Index ) */ CommandList().BeginQuery( d3dQuery->m_heap->GetHeap(), d3dQuery->m_type, d3dQuery->m_offset); CommandList().EndQuery( d3dQuery->m_heap->GetHeap(), d3dQuery->m_type, d3dQuery->m_offset);
쿼리의 종류는 D3D12_QUERY_TYPE 열거형을 통해 정하며 첫 번째 인자로 전달한 쿼리 힙이 지원하는 종류로 설정할 필요가 있습니다. 세부 내용은 msdn 문서를 참고 바랍니다.
단일 쿼리 객체를 생성해서 사용하던 Direct3D 11과 달리 Driect3D 12에서는 쿼리의 배열을 선언해서 인덱스로 단일 객체를 사용하는 방식으로 변경되었다고 할 수 있을 것 같습니다.
질의 결과 얻기
함수 호출 하나로 질의 결과를 얻을 수 있던 Direct3D 11과는 달리 Direct3D 12에서 질의 결과를 얻기 위해서는 준비 과정이 필요합니다. 우선 Cpu에서 읽을 수 있는 Readback Buffer가 필요합니다.
D3D12_HEAP_PROPERTIES bufferHeapProperties = { .Type = D3D12_HEAP_TYPE_READBACK, .CPUPageProperty = D3D12_CPU_PAGE_PROPERTY_UNKNOWN, .MemoryPoolPreference = D3D12_MEMORY_POOL_UNKNOWN, .CreationNodeMask = 0, .VisibleNodeMask = 0 }; D3D12_RESOURCE_DESC bufferDesc = { .Dimension = D3D12_RESOURCE_DIMENSION_BUFFER, .Alignment = 0, .Width = DefaultBlockSize, /* 질의 결과 자료형 크기에 맞는 적절한 크기로 설정해야 함 */ .Height = 1, .DepthOrArraySize = 1, .MipLevels = 1, .Format = DXGI_FORMAT_UNKNOWN, .SampleDesc = { .Count = 1, .Quality = 0, }, .Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR, .Flags = D3D12_RESOURCE_FLAG_NONE }; /* 질의 결과가 복사될 목적지이므로 기본 상태를 D3D12_RESOURCE_STATE_COPY_DEST 로 설정 */ hr = D3D12Device().CreateCommittedResource( &bufferHeapProperties, D3D12_HEAP_FLAG_NONE, &bufferDesc, D3D12_RESOURCE_STATE_COPY_DEST, nullptr, IID_PPV_ARGS( &m_readBackBuffer ) ); m_readBackBuffer->Map( 0, nullptr, &m_readBackPtr );
그리고 쿼리 힙에서 해당 버퍼로 질의 결과를 복사합니다. 이는 ID3D12GraphicsCommandList의 ResolveQueryData 함수를 통해 이뤄집니다.
ID3D12QueryHeap* heap = d3dQueryHeap->GetHeap(); ID3D12Resource* readBackBuffer = d3dQueryHeap->GetReadBackBuffer(); CommandList().ResolveQueryData( heap, /* 쿼리 힙에 대한 포인터 */ type, /* 쿼리 종류 */ offset, /* 쿼리 힙에 대한 시작 Index */ numQueries, /* 복사할 결과 갯수 */ readBackBuffer, /* Readback Buffer에 대한 포인터 */ sizeof( uint64 ) * offset /* Readback Buffer에 복사될 위치, 8byte 정렬되야 함 */ );
이제 미리 Map해 놓은 Readback Buffer의 CPU 포인터를 통해 복사된 결과에 접근하면 됩니다.
void D3D12QueryHeapBlock::GetData( void* outData, int32 size, int32 offset ) { auto src = static_cast<uint8*>( m_readBackPtr ) + size * offset; std::memcpy( outData, src, size ); }
지금까지 Direct3D 11/12에서의 쿼리 객체에 대한 기본적인 사용법을 알아보았고 이제는 쿼리 객체로 할 수 있는 것이 무엇인지 두 가지 예시를 보도록 하겠습니다.
GPU Timer
쿼리 객체로 할 수 있는 첫 번째 예시는 바로 GPU Timer입니다. 다음과 같이 시작 시간과 종료 시간을 구해 빼면 프로그램의 실행 시간을 측정할 수 있습니다.
#include <chrono> using ::std::chrono::steady_clock; // ... auto begin = steady_clock::now(); /* 시작 시간 */ /* 시간을 재려고 하는 작업을 수행 */ auto end = steady_clock::now(); /* 종료 시간 */ auto duration = end - begin; /* 실행 시간 */
이렇게 측정한 실행 시간의 프로그램을 실행하는데 있어 CPU가 소비하는 시간입니다. 이와 마찬가지로 GPU가 소비하는 시간도 측정할 수 있으며 여기에 쿼리 객체가 이용됩니다. Direct3D 11/12의 GPU Timer 클래스를 살펴보기 전 인터페이스를 우선 살펴보겠습니다.
class Query : public GraphicsApiResource { public: virtual void Begin( ICommandListBase& commandList ) = 0; virtual void End( ICommandListBase& commandList ) = 0; }; class GpuTimer : public Query { public: AGL_DLL static RefHandle<GpuTimer> Create(); virtual double GetDuration() = 0; };
GpuTimer는 Query 클래스를 상속받도록 구현하였습니다. Begin 함수와 End 함수를 오버라이드할 수 있도록 하여 Begin 함수에서는 시작 시간을 재고 End 함수에서 종료 시간을 재도록 합니다. CPU 시간을 쟀던 것 처럼 측정하고자 하는 GPU 작업이 시작되기 전 Begin 함수를 호출하고 GPU 작업이 끝나면 End 함수를 호출합니다. GPU가 소비한 시간은 GetDuration 함수를 통해 얻을 수 있도록 합니다.
Direct3D 11
Direct3D 11의 GPU Timer인 D3D11GpuTimer 클래스는 다음과 같이 선언하였습니다.
class D3D11GpuTimer final : public GpuTimer { public: virtual void InitResource() override; virtual void FreeResource() override; virtual void Begin( ICommandListBase& commandList ) override; virtual void End( ICommandListBase& commandList ) override; virtual double GetDuration() override; private: ID3D11Query* m_timeStampBegin = nullptr; ID3D11Query* m_timeStampEnd = nullptr; ID3D11Query* m_timeStampDisjoint = nullptr; };
총 3개의 쿼리 객체가 사용되는데 시작과 종료 쿼리 객체는 D3D11_QUERY_TIMESTAMP로 생성하고 추가적인 쿼리 객체는 D3D11_QUERY_TIMESTAMP_DISJOINT로 생성합니다.
void D3D11GpuTimer::InitResource() { D3D11_QUERY_DESC desc = { .Query = D3D11_QUERY_TIMESTAMP, .MiscFlags = 0, }; D3D11Device().CreateQuery( &desc, &m_timeStampBegin ); D3D11Device().CreateQuery( &desc, &m_timeStampEnd ); desc.Query = D3D11_QUERY_TIMESTAMP_DISJOINT; D3D11Device().CreateQuery( &desc, &m_timeStampDisjoint ); }
D3D11_QUERY_TIMESTAMP_DISJOINT는 두 개의 타임 스탬프 쿼리가 신뢰할 수 있는 값을 반환하는지 여부를 확인하고 경과된 틱을 초로 변환할 수 있는 정보를 제공합니다. 이렇게 생성한 쿼리 객체는 다음과 같이 사용됩니다.
void D3D11GpuTimer::Begin( ICommandListBase& commandList ) { /* DISJOINT 쿼리는 구간을 정해야 하므로 Begin을 호출합니다. */ commandList.BeginQuery( m_timeStampDisjoint ); /* 타임 스탬프의 경우 구간을 정할 필요가 없으므로 End로 사용합니다. */ commandList.EndQuery( m_timeStampBegin ); } void D3D11GpuTimer::End( ICommandListBase& commandList ) { commandList.EndQuery( m_timeStampEnd ); /* DISJOINT 쿼리 구간의 종료를 위해 End를 호출합니다. */ commandList.EndQuery( m_timeStampDisjoint ); }
그리고 GetDuration 함수는 다음과 같습니다.
double D3D11GpuTimer::GetDuration() { /* 타임 스탬프 쿼리의 반환 값은 8Byte 정수입니다. */ uint64 beginTime = 0; while ( D3D11Context().GetData( m_timeStampBegin, &beginTime, sizeof( beginTime ), 0 ) != S_OK ); uint64 endTime = 0; while ( D3D11Context().GetData( m_timeStampEnd, &endTime, sizeof( endTime ), 0 ) != S_OK ); /* DISJOINT 스탬프 쿼리의 반환 값은 D3D11_QUERY_DATA_TIMESTAMP_DISJOINT 구조체 입니다. */ D3D11_QUERY_DATA_TIMESTAMP_DISJOINT disjointData = {}; while ( D3D11Context().GetData( m_timeStampDisjoint, &disjointData, sizeof( disjointData ), 0 ) != S_OK ); /* D3D11_QUERY_DATA_TIMESTAMP_DISJOINT의 Disjoint가 false이면 신뢰할 수 있습니다. */ if ( disjointData.Disjoint == false ) { auto deltaTime = static_cast<double>( endTime - beginTime ); /* D3D11_QUERY_DATA_TIMESTAMP_DISJOINT의 Frequency를 통해 틱을 초로 변환할 수 있습니다. */ auto frequency = static_cast<double>( disjointData.Frequency ); return deltaTime / frequency * 1000.0; // millisecond } return 0.0; }
Direct3D 12
Direct3D 12의 Gpu Timer인 D3D12GpuTimer 클래스는 다음과 같이 선언하였습니다.
class D3D12GpuTimer final : public GpuTimer { public: virtual void InitResource() override; virtual void FreeResource() override; virtual void Begin( ICommandListBase& commandList ) override; virtual void End( ICommandListBase& commandList ) override; virtual double GetDuration() override; private: D3D12Query m_timeStampBegin; D3D12Query m_timeStampEnd; };
2개의 쿼리 객체만 사용되며 D3D12_QUERY_TYPE_TIMESTAMP로 생성합니다.
void D3D12GpuTimer::InitResource() { m_timeStampBegin = D3D12AllocatorForQuery().Allocate( D3D12_QUERY_TYPE_TIMESTAMP ); m_timeStampEnd = D3D12AllocatorForQuery().Allocate( D3D12_QUERY_TYPE_TIMESTAMP ); }
End로 사용하는 것은 Direct3D 11과 차이가 없으나 값을 가져오기 위해 ResolveQueryData 함수를 호출해야 하는 것을 잊으면 안됩니다.
void D3D12GpuTimer::Begin( ICommandListBase& commandList ) { commandList.EndQuery( &m_timeStampBegin ); } void D3D12GpuTimer::End( ICommandListBase& commandList ) { auto& d3d12CommandList = static_cast<ID3D12CommandListEX&>( commandList ); d3d12CommandList.EndQuery( &m_timeStampEnd ); /* 쿼리 힙에 연속해서 있는 경우 ResolveQueryData를 한 번에 호출할 수 있습니다. */ bool isContinuous = ( m_timeStampBegin.m_heap == m_timeStampEnd.m_heap ) && ( std::abs( m_timeStampBegin.m_offset - m_timeStampEnd.m_offset ) == 1 ); if ( isContinuous ) { int32 offset = std::min( m_timeStampBegin.m_offset, m_timeStampEnd.m_offset ); d3d12CommandList.ResolveQueryData( m_timeStampBegin.m_heap, D3D12_QUERY_TYPE_TIMESTAMP, offset, 2 ); } else { d3d12CommandList.ResolveQueryData( m_timeStampBegin.m_heap, D3D12_QUERY_TYPE_TIMESTAMP, m_timeStampBegin.m_offset, 1 ); d3d12CommandList.ResolveQueryData( m_timeStampEnd.m_heap, D3D12_QUERY_TYPE_TIMESTAMP, m_timeStampEnd.m_offset, 1 ); } }
그리고 GetDuration 함수는 다음과 같습니다. 틱을 초로 바꾸기 위해 Command Queue의 GetTimestampFrequency 함수를 호출하고 있는 부분을 주목하시기 바랍니다.
/* Direct3D 11과 달리 이 함수는 대기하도록 구현하지 않았습니다. 따라서 알맞은 값을 가져오기 위해 로직 단계에서 적절한 처리를 할 필요가 있습니다. 저는 별도의 대기를 걸지 않고 과거 프레임의 데이터를 지연해서 가져오도록 하여 알맞은 값을 가져올 수 있도록 하였습니다. 세부 내용이 궁금하시면 GPUProfiler.cpp를 참고하시기 바랍니다. */ double D3D12GpuTimer::GetDuration() { uint64 beginTime = 0; m_timeStampBegin.m_heap->GetData( &beginTime, sizeof( beginTime ), m_timeStampBegin.m_offset ); uint64 endTime = 0; m_timeStampEnd.m_heap->GetData( &endTime, sizeof( endTime ), m_timeStampEnd.m_offset ); uint64 gpuFrequency = 0; D3D12DirectCommandQueue().GetTimestampFrequency( &gpuFrequency ); if ( endTime > beginTime ) { auto deltaTime = static_cast<double>( endTime - beginTime ); auto frequency = static_cast<double>( gpuFrequency ); return deltaTime / frequency * 1000.0; // millisecond } return 0.0; }
결과로 다음과 같이 실시간으로 GPU가 시간을 얼마나 사용하는지에 대한 데이터를 얻을 수 있습니다.
Hardware Occlusion Culling
두 번째 예시는 Hardware Occlusion Culling입니다. Occlusion Culling(이하 오클루전 컬링)은 렌더링해야 할 물체가 다른 물체에 가려질 경우에 해당 물체에 대한 드로우 콜을 제거하여 GPU 비용을 절감하는 기법입니다.
오클루전 컬링을 구현하는 방법은 여러가지 있으나 쿼리 객체를 사용하면 GPU를 이용하여 오클루전 컬링을 구현할 수 있고 이를 Hardware Occlusion Culling이라 합니다. GPU Timer때와 마찬가지로 인터페이스부터 살펴보겠습니다.
class OcclusionQuery : public Query { public: AGL_DLL static RefHandle<OcclusionQuery> Create(); virtual uint64 GetNumSamplePassed() = 0; virtual bool IsDataReady() const = 0; };
GetNumSamplePassed 함수는 쿼리의 결과로 가려지지 않고 그려진 픽셀이 몇 개 인지를 반환합니다. IsDataReady 함수는 쿼리의 결과가 준비되었는지 확인하는 함수로 쿼리의 결과를 가져오는 함수가 비동기로 동작하기 때문에 결과가 준비된 경우만 사용하기 위함입니다.
Direct3D 11
Direct3D 11의 OcclusionQuery 클래스인 D3D11OcclusionTest의 선언은 다음과 같습니다.
class D3D11OcclusionTest final : public OcclusionQuery { public: virtual void InitResource() override; virtual void FreeResource() override; virtual void Begin( ICommandListBase& commandList ) override; virtual void End( ICommandListBase& commandList ) override; virtual uint64 GetNumSamplePassed() override; virtual bool IsDataReady() const override; private: ID3D11Query* m_occlusionTest = nullptr; };
생성 시에는 D3D11_QUERY_OCCLUSION로 다음과 같이 생성합니다.
void D3D11OcclusionTest::InitResource() { D3D11_QUERY_DESC desc = { .Query = D3D11_QUERY_OCCLUSION, .MiscFlags = 0, }; D3D11Device().CreateQuery( &desc, &m_occlusionTest ); }
Begin, End, GetNumSamplePassed는 기본적인 쿼리 객체 사용법과 크게 다르지 않습니다.
void D3D11OcclusionTest::Begin( ICommandListBase& commandList ) { commandList.BeginQuery( m_occlusionTest ); } void D3D11OcclusionTest::End( ICommandListBase& commandList ) { commandList.EndQuery( m_occlusionTest ); } uint64 D3D11OcclusionTest::GetNumSamplePassed() { uint64 numSamplePassed = std::numeric_limits<uint64>::max(); while ( D3D11Context().GetData( m_occlusionTest, &numSamplePassed, sizeof( numSamplePassed ), 0 ) != S_OK ); return numSamplePassed; }
GetNumSamplePassed 함수에서 대기를 하도록 구현하기 때문에 IsDataReady 함수는 다음과 같이 항상 True를 반환하도록 하였습니다.
💡 물론 대기를 하지 않도록 할 수도 있으나 샘플 코드는 오클루전 결과를 가져오는 스레드와 스왑체인을 전환하는 스레드가 동일하기 때문에 Direct3D 11의 동기화로 인해 쿼리 결과가 준비되지 못한 경우가 없기 때문에 이 정도로 충분하다고 생각됩니다.
bool D3D11OcclusionTest::IsDataReady() const { return true; }
Direct3D 12
Direct3D 12의 OcclusionQuery 클래스인 D3D11OcclusionTest의 선언은 다음과 같습니다.
class D3D12OcclusionTest final : public OcclusionQuery { public: virtual void InitResource() override; virtual void FreeResource() override; virtual void Begin( ICommandListBase& commandList ) override; virtual void End( ICommandListBase& commandList ) override; virtual uint64 GetNumSamplePassed() override; virtual bool IsDataReady() const override; private: D3D12Query m_occlusionTest; ID3D12Fence* m_completionFence = nullptr; };
Begin, End, GetNumSamplePassed는 다음과 같습니다. IsDataReady 함수를 위한 펜스 객체에 대한 시그널 함수 호출이 다른 점이라고 할 수 있을 것 같습니다.
void D3D12OcclusionTest::Begin( ICommandListBase& commandList ) { /* 펜스에 쿼리 결과 준비중 값을 설정합니다. */ m_completionFence->Signal( 0 ); commandList.BeginQuery( &m_occlusionTest ); } void D3D12OcclusionTest::End( ICommandListBase& commandList ) { auto& d3d12CommandList = static_cast<ID3D12CommandListEX&>( commandList ); d3d12CommandList.EndQuery( &m_occlusionTest ); d3d12CommandList.ResolveQueryData( m_occlusionTest.m_heap, D3D12_QUERY_TYPE_OCCLUSION, m_occlusionTest.m_offset, 1 ); /* * 쿼리 결과를 가져오면 펜스를 준비 완료 값으로 설정합니다. */ d3d12CommandList.Signal( m_completionFence, 1 ); } uint64 D3D12OcclusionTest::GetNumSamplePassed() { uint64 numSamplePassed = std::numeric_limits<uint64>::max(); m_occlusionTest.m_heap->GetData( &numSamplePassed, sizeof( numSamplePassed ), m_occlusionTest.m_offset ); return numSamplePassed; } /* 참고용 CommandList Signal 함수 구현 */ void D3D12CommandListImpl::Signal( ID3D12Fence* fence, uint64 fenceValue ) { if ( fence == nullptr ) { return; } /* 커맨드 큐에 제출된 다음 설정해야 하므로 지연시킵니다. */ m_fenceBatch.emplace_back( fence, fenceValue ); } void D3D12CommandListImpl::OnCommited() { D3D12DirectCommandQueue().Signal( m_cmdListResource.m_fence.Get(), 1 ); /* * 지연된 펜스에 대한 시그널을 함수를 호출합니다. */ for ( const auto& pair : m_fenceBatch ) { D3D12DirectCommandQueue().Signal( pair.first, pair.second ); } m_cmdListResource = D3D12CmdPool( D3D12_COMMAND_LIST_TYPE_DIRECT ).ObtainCommandList(); m_stateCache.Prepare(); m_fenceBatch.clear(); }
IsDataReady 함수는 펜스 객체를 통해 결과가 유효한지 여부를 판단합니다.
bool D3D12OcclusionTest::IsDataReady() const { return m_completionFence && ( m_completionFence->GetCompletedValue() == 1 ); }
가시성 판단
구현한 쿼리 객체를 바탕으로 가시성 판단을 합니다. GetNumSamplePassed 함수의 결과로 렌더링된 픽셀의 갯수가 0이라면 물체가 가려진 것으로 판단할 수 있습니다. 전체 코드를 보고 세부 사항을 이야기 하겠습니다.
void OcclusionCull( const IScene& scene, GlobalDynamicVertexBuffer& outDynamicVertexBuffer, RenderViewInfo& outViewInfo, RenderThreadFrameData<OcclusionRenderData>& outOcclusionRenderData ) { assert( outViewInfo.m_state != nullptr ); RenderViewState& viewState = *outViewInfo.m_state; Plane nearPlane = Frustum( outViewInfo.m_viewProjMatrix ).GetPlane( Frustum::PlaneDir::Near ); const auto& occlusionBounds = scene.PrimitiveOcclusionBounds(); const auto& primitives = scene.Primitives(); for ( auto primitive : primitives ) { uint32 primitiveId = primitive->PrimitiveId(); /* 이전에 절두체 컬링으로 컬링된 경우 제외 */ if ( outViewInfo.m_visibilityMap[primitiveId] == false ) { continue; } auto found = viewState.m_occlusionTestDataSet.find( primitiveId ); if ( found == std::end( viewState.m_occlusionTestDataSet ) ) { found = viewState.m_occlusionTestDataSet.emplace( primitiveId, OcclusionTestData() ).first; continue; } OcclusionTestData& occlusionTestData = found->second; bool occluded = occlusionTestData.WasOccluded(); agl::OcclusionQuery* occlusionQuery = occlusionTestData.GetLatestOuery( viewState.m_occlusionFrameCounter ); if ( occlusionQuery != nullptr ) { occluded = ( occlusionQuery->GetNumSamplePassed() == 0 ); } else { occluded = false; } const BoxSphereBounds& bounds = occlusionBounds[primitiveId]; if ( BoxAndPlane( bounds.Origin(), bounds.HalfSize(), nearPlane ) == CollisionResult::Intersection ) { occluded = false; } occlusionTestData.UpdateOccludeState( occluded ); outViewInfo.m_visibilityMap[primitiveId] = (occluded == false); OcclusionRenderData& occlusionRenderData = outOcclusionRenderData.emplace_back( primitiveId, outDynamicVertexBuffer.Allocate( sizeof( Vector ) * NumOcclusionBoxVertex ) ); BuildOcclusionBox( occlusionRenderData.m_allcationInfo.m_lockedMemory, bounds ); } }
가장 먼저 현재 절두체의 근평면을 가져오는 코드를 볼 수 있습니다.
Plane nearPlane = Frustum( outViewInfo.m_viewProjMatrix ).GetPlane( Frustum::PlaneDir::Near );
오클루전 검사 렌더링은 렌더링 비용을 최소화하기 위해서 물체의 바운딩 박스로 렌더링하는데 커다란 물체의 경우 바운딩 박스안에 카메라가 위치하는 경우 오클루전 테스트의 결과를 신용할 수 없기 때문입니다.
💡 바운딩 박스가 자기 자신에 의해서 가려지는 경우가 있습니다.
다음으로 가시성 판단을 할 오브젝트를 순회하면서 쿼리 객체가 존재하는지 확인하고 없으면 생성합니다.
auto found = viewState.m_occlusionTestDataSet.find( primitiveId ); if ( found == std::end( viewState.m_occlusionTestDataSet ) ) { found = viewState.m_occlusionTestDataSet.emplace( primitiveId, OcclusionTestData() ).first; continue; }
이전에 쿼리 객체를 생성했었다면 가장 가까운 과거 프레임의 쿼리 객체를 가져옵니다. 과거 프레임의 쿼리 객체가 없다면 가려진 것으로 판단합니다.
agl::OcclusionQuery* occlusionQuery = occlusionTestData.GetLatestOuery( viewState.m_occlusionFrameCounter ); if ( occlusionQuery != nullptr ) { occluded = ( occlusionQuery->GetNumSamplePassed() == 0 ); } else { occluded = false; }
샘플 프로그램에서는 최대 5프레임의 과거 쿼리 객체를 보존하고 있기 때문에 이들을 다음과 같이 순회하면 가장 가까운 객체를 찾습니다.
static constexpr int32 MaxOcclusionQueryLatency = 5; agl::OcclusionQuery* OcclusionTestData::GetLatestOuery( uint64 currentOcclusionFrame ) { agl::OcclusionQuery* properQuery = nullptr; int64 minOcclusionFrameDiff = MaxOcclusionQueryLatency; for ( int32 i = 0; i < MaxOcclusionQueryLatency; ++i ) { int32 poolIndex = GetCurrentOcclusionTestIndex( currentOcclusionFrame + i ); if ( m_occlusionQuery[poolIndex] == nullptr ) { continue; } /* 쿼리 결과가 준비되지 않았으면 넘어감 */ if ( m_occlusionQuery[poolIndex]->IsDataReady() == false ) { continue; } /* 프레임 차이가 가장 작은 쿼리 객체를 반환 */ int64 occlusionFrameDiff = currentOcclusionFrame - m_frameCounter[poolIndex]; if ( occlusionFrameDiff < minOcclusionFrameDiff ) { properQuery = m_occlusionQuery[poolIndex]; minOcclusionFrameDiff = occlusionFrameDiff; } } return properQuery; }
그리고 맨 처음 구해 놓은 근평면에 물체가 충돌한 상태인지 판단하여 충돌했으면 가려지지 않은 것으로 간주합니다.
const BoxSphereBounds& bounds = occlusionBounds[primitiveId]; if ( BoxAndPlane( bounds.Origin(), bounds.HalfSize(), nearPlane ) == CollisionResult::Intersection ) { occluded = false; }
그리고 오클루전 검사 렌더링을 위해 동적 버택스 버퍼에 공간을 할당 받아 바운딩 박스로 초기화해 놓습니다.
OcclusionRenderData& occlusionRenderData = outOcclusionRenderData.emplace_back( primitiveId, outDynamicVertexBuffer.Allocate( sizeof( Vector ) * NumOcclusionBoxVertex ) ); BuildOcclusionBox( occlusionRenderData.m_allcationInfo.m_lockedMemory, bounds ); void BuildOcclusionBox( void* dest, const BoxSphereBounds& bounds ) { Vector min = bounds.Origin() - bounds.HalfSize(); Vector max = bounds.Origin() + bounds.HalfSize(); auto vertex = static_cast<Vector*>( dest ); // front *( vertex++ ) = Vector( min.x, min.y, min.z ); *( vertex++ ) = Vector( min.x, max.y, min.z ); *( vertex++ ) = Vector( max.x, max.y, min.z ); *( vertex++ ) = Vector( min.x, min.y, min.z ); *( vertex++ ) = Vector( max.x, max.y, min.z ); *( vertex++ ) = Vector( max.x, min.y, min.z ); /* 박스 렌더링의 위한 버택스 설정 나머지 면에 대한 코드는 생략 */ }
오클루전 검사 렌더링
오클루전 검사 렌더링에는 준비물이 필요한데 미리 그려 놓은 깊이 버퍼가 필요합니다. 샘플 프로그램은 PrePass 라고 부르는 단계에서 씬의 깊이를 미리 그려 놓았기 때문에 이를 바인딩 해놓았습니다.
void ForwardRenderer::RenderOcclusionTest( RenderViewGroup& renderViewGroup, uint32 viewIndex ) { auto [width, height] = renderViewGroup.GetViewport().Size(); /* 깊이 버퍼 세팅 */ RenderingOutputContext context = { .m_renderTargets = { renderViewGroup.GetViewport().Texture() }, .m_depthStencil = m_renderTargets.GetDepthStencil(), .m_viewport = { .m_left = 0.f, .m_top = 0.f, .m_front = 0.f, .m_right = static_cast<float>( width ), .m_bottom = static_cast<float>( height ), .m_back = 1.f }, .m_scissorRects = { .m_left = 0L, .m_top = 0L, .m_right = static_cast<int32>( width ), .m_bottom = static_cast<int32>( height ) } }; StoreOuputContext( context ); auto commandList = GetCommandList(); GPU_PROFILE( commandList, Occlusion ); ApplyOutputContext( commandList ); DoRenderOcclusionTest( m_shaderResources, m_viewInfo[viewIndex], m_occlusionRenderData ); }
이제 오클루전 검사 렌더링이 진행되는 DoRenderOcclusionTest 함수를 보겠습니다. 해당 함수에서는 가시성 검사 단계에서 준비한 버택스 버퍼를 통해 바운딩 박스를 그립니다. 드로우 콜 기록 전 후에 BeginQuery, EndQuery가 위치한 점에 주목하시기 바랍니다.
void DoRenderOcclusionTest( RenderingShaderResource& resources, RenderViewInfo& viewInfo, const RenderThreadFrameData<OcclusionRenderData>& occlusionRenderData ) { if ( occlusionRenderData.empty() ) { return; } assert( viewInfo.m_state != nullptr ); RenderViewState& viewState = *viewInfo.m_state; auto commandList = GetCommandList(); for ( const OcclusionRenderData& renderData : occlusionRenderData ) { auto found = viewState.m_occlusionTestDataSet.find( renderData.m_primitiveId ); if ( found == std::end( viewState.m_occlusionTestDataSet ) ) { continue; } /* 오클루전 쿼리 시작 */ found->second.BeginQuery( viewState ); /* 바운딩 박스 드로우 커맨드를 위한 준비 */ auto snapshot = BuildOcclusionDrawSnapshot( renderData.m_allcationInfo ); resources.BindResources( snapshot.m_pipelineState.m_shaderState, snapshot.m_shaderBindings ); VisibleDrawSnapshot visibleSnapshot = { .m_primitiveId = 0, .m_primitiveIdOffset = 0, .m_numInstance = 1, .m_snapshotBucketId = -1, .m_drawSnapshot = &snapshot, }; /* 드로우 커맨드 제출 */ VertexBuffer emptyPrimitiveID; CommitDrawSnapshot( commandList, visibleSnapshot, emptyPrimitiveID ); /* 오클루전 쿼리 끝 */ found->second.EndQuery( viewState ); } }
드로우 커맨드를 준비하는 BuildOcclusionDrawSnapshot 함수에서는 렌더링을 위한 각종 파이프 라인 세팅이 이뤄지는데 래스터라이저 상태에서 후면 컬링을 끈 것과 깊이 테스트시 깊이 쓰기를 끈 것이 주요 부분입니다. 그리고 이 함수를 유심히 보면 픽셀 셰이더를 설정하지 않았는데 Direct3D의 경우 렌더 타겟에 렌더링 없이 깊이 테스트만 하는 경우에 픽셀 셰이더가 바인딩되지 않아도 되기 때문입니다.
💡 다른 Graphics API의 경우에도 해당하는 사항인지는 잘 모르는 점 양해 바랍니다.
DrawSnapshot BuildOcclusionDrawSnapshot( const GlobalDynamicVertexBuffer::AllocationInfo& allcationInfo ) { OcclusionVS occlusionVS; /* VertexLayout 설정 */ VertexStreamLayout vertexlayout; vertexlayout.AddLayout( "POSITION", 0, agl::ResourceFormat::R32G32B32_FLOAT, 0, false, 0, 0 ); /* 래스터라이저 상태 설정 */ RasterizerOption occlusionPassRasterizerOption; occlusionPassRasterizerOption.m_cullMode = agl::CullMode::None; occlusionPassRasterizerOption.m_scissorEnable = true; /* 깊이 스텐실 상태 설정 */ DepthStencilOption occlusionPassDepthOption; occlusionPassDepthOption.m_depth.m_depthFunc = agl::ComparisonFunc::Less; occlusionPassDepthOption.m_depth.m_writeDepth = false; DrawSnapshot snapshot; /* 버택스 버퍼 설정 */ snapshot.m_vertexStream.Bind( allcationInfo.m_buffer, 0, sizeof( Vector ), allcationInfo.m_offset ); snapshot.m_primitiveIdSlot = -1; snapshot.m_pipelineState.m_shaderState.m_vertexLayout = GraphicsInterface().FindOrCreate( *occlusionVS, vertexlayout ); /* 셰이더 설정. 버택스 셰이더만 설정하는 점에 주목 */ snapshot.m_pipelineState.m_shaderState.m_vertexShader = occlusionVS; snapshot.m_pipelineState.m_rasterizerState = GraphicsInterface().FindOrCreate( occlusionPassRasterizerOption ); snapshot.m_pipelineState.m_depthStencilState = GraphicsInterface().FindOrCreate( occlusionPassDepthOption ); snapshot.m_pipelineState.m_primitive = agl::ResourcePrimitive::Trianglelist; /* 렌더링에 필요한 상수 버퍼등의 리소스 바인딩 */ auto initializer = CreateShaderBindingsInitializer( snapshot.m_pipelineState.m_shaderState ); snapshot.m_shaderBindings.Initialize( initializer ); /* 렌더링할 버택스 갯수 */ snapshot.m_count = NumOcclusionBoxVertex; /* 파이프라인 상태 준비 */ PreparePipelineStateObject( snapshot ); return snapshot; }
마치며
준비한 내용은 여기까지 입니다. 제 개인 프로젝트 구현의 전체 코드는 아래 github 변경점을 참고 부탁드립니다.
GitHub - xtozero/SSR at culling
Reference
- Unreal Engine 5.4
- https://therealmjp.github.io/posts/profiling-in-dx11-with-queries/
'개인 프로젝트' 카테고리의 다른 글
오브젝트 피킹 (Object Picking) (0) 2024.07.13 Specular IBL (0) 2024.05.24 Bindless Resource (0) 2024.03.20 빛 전파 볼륨 (Light Propagation Volume) (0) 2024.01.13 화면 공간 반사 ( Screen Space Reflection ) (0) 2023.12.22