개인 공부용으로 번역한 거라 잘못 번역된 내용이 있을 수 있습니다.
또한 원작자의 동의 없이 올려서 언제든 글이 내려갈 수 있습니다.
출처 : https://wickedengine.net/2021/05/06/graphics-api-abstraction/
Wicked Engine은 여러 그래픽스 API(이 글을 쓰는 현재는 DX11, DX12 그리고 Vulkan)으로 today’s advanced rendering effects 를 처리할 수 있습니다. 이것이 가능하게 하는 핵심은 좋은 그래픽스 추상화를 사용하는 것입니다, 그래서 이런 복잡한 알고리즘은 한 번만 작성되면 됩니다.
모든 게임엔진 코드에 개발되는 모든 공용 인터페이스는 어떤 API가 실행될지 모른 상태로, 렌더링 개념을 정의하는 데 사용될 수 있습니다. 인터페이스는 아래처럼 분류할 수 있습니다:
- resource
- device
이것들을 아래에서 알아봅시다.
Resources
기능이 없는 간단한 데이터 구조들입니다. 아주 간단하고, plain old data(POD) 타입입니다, 예를 들어 descriptor structures 나 enums 입니다.
예를 들어 모든 가능한 Cull mode는 enum으로 나타냅니다:
enum CULL_MODE { CULL_NONE, CULL_FRONT, CULL_BACK, };
텍스쳐는 struct로 나타냅니다:
struct TextureDesc { uint32_t Width = 0; uint32_t Height = 0; uint32_t MipLevels = 1; // ... };
나는 initial default value를 모든 것에 제공하는 것을 좋아합니다, 그것은 더 이상 POD가 아니게 만듭니다, 그러나 사용하는데 많은 편의를 줍니다. 내가 가장 좋아하는 POD도 있습니다, 사용자가 정의한 생성자, 소멸자, move 그리고 copy 정의들이 없습니다, 그들은 기본 기능을 사용하도록 가정합니다.
몇몇의 리소스는 실제 GPU 데이터를 관리할 책임이 있습니다. 예를 들어: Texture, GPUBuffer, Sampler, PipelineState, 등등. 텍스쳐 리소스는 아래와 같습니다:
struct Texture { TextureDesc desc; std::shared_ptr<void> internal_state; };
첫째로, TextureDesc는 텍스쳐에 대한 설명과 어플리케이션/엔진에서 보여집니다. 이것은 편의상 여기에 저장됩니다, 그러나 실제 GPU가 이 리소스를 반드시 사용하는 것을 나타내진 않습니다, 그것은 internal_state 포인터가 가리키는 구현에 의해 결정됩니다, 그래서 리소스가 실제로 “created” (간단하게 표현) 후에 존재합니다. internal_state 는 void 포인터입니다, 그 말은 구현이 추상적이라는 것입니다, 이것은 리소스가 “created” 때 결정됩니다. “shared_ptr”은 아래의 이유로 선택됩니다:
- void* 포인터가 아닐 때, shared_ptr은 어떻게 기반 오브젝트를 제거할지 알고 있습니다. (메타 데이터를 추가로 저장하기 때문)
- copy, move 기능이 제공됩니다, 그래서 우리는 수많은 생성자와 소멸자 기타 등등을 손수 작성하지 않아도 됩니다.
- 레퍼런스 카운팅을 사용하여 오브젝트가 더 이상 사용되지 않을 때 지워줍니다. 이것은 엔진 쪽에서 그것을 사용하는데 굉장히 편하게 해줍니다, 예를 들어: std::vector 는 여러 텍스쳐를 저장하는 데 사용될 수 있습니다, 텍스쳐 삭제는 컨테이너에서 제거하는 것으로 가능합니다, 그것이 언제 어떻게 소멸되는지 걱정 없이(GPU 리소스의 소멸 요구사항은 그래픽스 API에 따라 많이 다릅니다). 오브젝트는 영역을 벗어나거나 새 오브젝트가 원래 위치에 생성되는 경우 역시 제거됩니다.
구현은 공용 인터페이스의 변화 없이 internal_state가 어떤 것이든 가리킬 수 있는 구조로 정의됩니다. 예를 들어, DX11의 텍스쳐 구현은 아래와 같습니다:
struct Texture_DX11 { ComPtr<ID3D11Resource> resource; ComPtr<ID3D11ShaderResourceView> srv; // ... };
DX11 구현은 꽤 단순합니다, 내부적으로 그것은 자신의 레퍼런스 카운팅 오브젝트를 사용합니다, 그래서 심지어 소멸자도 없습니다(모든 것은 자동으로 소멸됩니다). DX11 CreateTexture() 함수는 이렇게 시작합니다:
bool CreateTexture(const TextureDesc* pDesc, const SubresourceData *pInitialData, Texture *pTexture) const { pTexture->internal_state = std::make_shared<Texture_DX11>(); // ...
internal_state에 할당하는 것은 이전에 만든 오브젝트가 있는 경우 삭제하고 새로운 것을 만듭니다.
불칸은 구현이 꽤 다르고 더 어러울 수 있습니다:
이것은 텍스쳐 오브젝트의 Vulkan 구현에 대한 코드 조각입니다, 그러나 이것을 보여주려는 주요 이유는 불칸은 레퍼런스 카운팅 개념이 없습니다, 그래서 리소스 소멸에 더 빡빡한 규칙이 있습니다. Custom allocator가 사용되며, destructor는 불칸 리소스를 deferred removal queue이 추가합니다, 이것은 리소스가 GPU에서 사용되는 동안 소멸되지 않게 하기 위해서입니다. 요점은, 이러한 구현은 오브젝트가 아주 유연하고 API가 요구하는 것만큼 복잡할 수 있다는 것입니다.
struct Texture_Vulkan { std::shared_ptr<GraphicsDevice_Vulkan::AllocationHandler> allocationhandler; VkImage resource = VK_NULL_HANDLE; VkImageView srv = VK_NULL_HANDLE; // ... ~Texture_Vulkan() { allocationhandler->destroylocker.lock(); allocationhandler->destroyer_images.push_back(std::make_pair(std::make_pair(resource, allocation), framecount)); allocationhandler->destroyer_imageviews.push_back(std::make_pair(srv, framecount)); allocationhandler->destroylocker.unlock(); //... } };
Device
리소스의 목적은 GPU 데이터를 유지하는 것입니다, 그러나 그들은 어떠한 기능도 제공하지 않습니다. 그래픽스 디바이스 인터페이스가 이것을 책임집니다. 이것이 추상화에서 가장 복잡한 부분이고 여기서 모든 API의 상세 구현을 포함합니다. 나는 이것을 위해 상속(inheritance)를 사용하기로 했습니다. 베이스 클래스는 GraphicsDevice라 부릅니다, 그리고 거의 구현이 없습니다. - 몇몇 helper 함수 제외. 이 인터페이스는 고유 API 클래스에서 구현될 수 있습니다, 이 클래스는 GraphicsDevice_DX11, GraphicsDevice_DX12 and GraphicsDevice_Vulkan 클래스가 있습니다. 개인적으로는 최소한의 코드 파일을 사용하는 것을 선호합니다, 그래서 각 고유 API 클래스는 header, cpp 쌍으로 구현됩니다(그러나 이것이 필수는 아닙니다). 이것은 고유 API 파일을 꽤 크게 만듭니다만 모든 기능을 한 곳에서 찾을 수 있습니다, 그래서 어디서 DX12 코드와 같은 부분을 찾을지 고민하지 않아도 되며 다른 부분에 의존되지 않습니다(비주얼 스튜디오의 Ctrl+M+O 단축키를 함수를 접어두는데 자주 사용합니다). 다음으로, GraphicsDevices가 제공하는 특징에 대해서 설명할 것입니다.
Creating resources
리소스는 반드시 먼저 생성됩니다, 이것은 GPU 쪽에 데이터가 생성된다는 의미입니다. CreateTexture(), CreateBuffer(), CreatePipelineState() 그리고 다른 함수들은 descriptor를 그들의 입력으로 요구합니다(예를 들면 TextureDesc), 그리고 그들은 리소스를 생성할 것입니다(예를 들어 Texture). 생성에 사용되는 Descriptor 파라메터는 이후에 가장 최근에 생성된 파라메터를 가리키기 위해서 리소스에 복사되어질 것입니다. 만약 리소스가 이미 생성되어있다면, 새로운 descriptor 파라메터로 다시 생성될 것입니다(이전 컨텐츠는 소멸되고 간단히 재생성될 것이란 의미입니다, 그러나 더 최적화된 경로의 실행이 가능할 수 있습니다, 예를 들면 스왑체인 크기 재조정). 생성 함수의 흐름은 DX11처럼 간결합니다, initData 파라메터라 null이 아니면 리소스는 초기값으로 초기화될 것입니다. 함수가 리턴된 후에, 리소스는 이미 사용할 준비가 되었다고 간주합니다.
텍스쳐 생성의 예제:
TextureDesc desc; desc.BindFlags = BIND_RENDER_TARGET | BIND_SHADER_RESOURCE; desc.Format = FORMAT_R10G10B10A2_UNORM; desc.Width = 3840; desc.Height = 2160; desc.SampleCount = 8; Texture texture; device->CreateTexture(&desc, nullptr, &texture);
DX11의 구현은 이 동작과 일치합니다만 DX12와 Vulkan은 올바른 동기화를 사용하여 리소스룰 GPU가 사용하는 때는 준비되어있는 것을 보장할 것입니다. 이 글을 쓰는 시점에는, GPU가 이전 복사 연산을 끝낼 때까지 기다리게 두고 동기화가 다음 submit에서 수행됩니다.
중요한 점은, 유저가 동기화를 지연할 수 있도록 대기 매커니즘을 노출시키는 것이 좋은 생각일 수 있습니다, 왜냐하면 bindless 리소스의 경우 API 레이어라 언제 쉐이더가 리소스에 접근할 수 있는지 정확히 알지 못하기 때문입니다, 그래서 다음 submit이 너무 빨라서 잠재적인 비동기 복사를 방해할 수 있습니다.
Subresources
버퍼와 텍스쳐 리소스는 리소스를 다른 Views로 확인하는 서브리소스로 부를 수 있습니다, 예를 들어, 여러 MIP level을 가진 텍스쳐는 각각의 mip level을 조사하기 위해 여러 서브리소스를 가집니다, 또는 mip 샘플링을 수행하기 위해서 전체 mipchain을 조사하기 위한 단일 서브리소스를 가질 수 있습니다. 버퍼의 경우, 서로 다른 서브리소스들이 버퍼의 다른 영역을 조사할 수 있게 생성될 수 있습니다. Read-only와 Read-write 서브리소스 또한 서로 다릅니다. 텍스쳐 버퍼나 버퍼를 생성할 때, 그리고 desciptor가 적절한 flags를 가짐, 구현은 항상 기본 서브리소스로 전체 리소스를 조사하는 형태로 만들어질 것입니다, 왜냐하면 이것이 가장 일반적으로 GPU에서 접근하는 방식이기 때문입니다. 만약 필요하다면, 더 세부적인 서브리소스를 GraphicsDevice::CreateSubresource() 함수를 사용해 만들 수 있습니다. 서브리소스는 main resource 할당과 함께 해야지 의미가 있습니다, 그들은 이 인터페이스에서 간단한 정수 숫자로 식별됩니다. 서브리소스가 숫자로 식별되는 것은 이점이 있습니다:
- 서브리소스의 라이프타임은 메인리소스의 라이프타임에 묶입니다. 메인리소스가 오래 살아있는 한, 서브리소스도 살아있음, 그러나 서브리소스는 메인 리소스를 살아있도록 유지시키지는 않을 것입니다.
- 유저는 서브리소스의 식별자를 반드시 저장할 필요는 없습니다, 그들이 계속하서 증가하는 숫자이기 때문입니다. 만약 특정 서브리소스가 각각의 mip을 위해 생성되었다면, 우리는 암묵적으로 어떤 숫자가 서브리소스를 참조하는지 압니다. 이것은 mip 생성, texture arrays, 3D textures, cubemaps에서 일반적인 경우입니다.
- 대부분의 경우, 우리는 전체 리소스 이외에 다른 것에 접근하길 원치 않습니다, 그래서 우리는 전체 리소스를 가정하기 위해 서브리소스 식별자를 모든 관련된 함수에서 생략하여 단순화할 수 있습니다. 관련된 함수의 예는 리소스를 쉐이더에 묶는 것입니다.
모든 개별 텍스쳐 mip level의 쉐이더 리소스 뷰를 생성하는 예제:
for (uint32_t i = 0; i < texture.desc.MipLevels; ++i) { int subresource = device->CreateSubresource( &texture, SRV, /*firstSlice=*/ 0, /*sliceCount=*/ 1, /*firstMip=*/ i, /*mipCount=*/ 1 ); }
Recording render commands
많은 양의 커맨드를 녹화할 때, CPU를 활용하는 것은 아주 중요합니다. 이것을 위해, CommandList는 기본적으로 인터페이스 관점으로 CPU 스레드를 식별하도록 인터페이스에 의해 노출됩니다. CommandList는 0 부터 시작하여 계속해서 증가하는 정수입니다. 새로운 CommandList는 GraphicsDevice::BeginCommandList() 함수로 시작할 수 있습니다, 그것은 우리에게 다음 커맨드리스트를 주고 올바른 상태에서 Graphics 커맨드를 녹화할 수 있게 준비하도록 보장해줍니다. 녹화하는 모든 커맨드는 CommandList 파라메터를 가집니다, 이 파라메터는 CommandList에 기록되어집니다, 그리고 한 번에 한 개의 스레드만 특정 CommandList에 기록해야 합니다. 이 모든 것들은 CPU 연산입니다, 그리고 실제 GPU 작업 없이 커맨드를 기록하면서 시작합니다. CommandList의 사용 예제입니다:
CommandList cmd = device->BeginCommandList(); Viewport vp; vp.Width = 100; vp.Height = 200; device->BindViewports(1, &vp, cmd);
CommandList는 숫자로 표시됩니다. 왜냐하면 당시에는 스레드 식별자로 숫자로 보았기 때문입니다, 그래서 몇몇 시스템은 CommandList로 인덱싱 할 수 있는 static arrays를 사용합니다. 또한 이것들은 엔진의 어떠한 곳에도 저장되지 않는 임시 리소스입니다, 그래서 그것은 internal_state를 사용하는 적절한 graphics 리소스가 되는 것은 상식에 맞지 않습니다. 현재는 아마도 계속해서 증가하는 숫자를 노출하기로 한 결정이 너무 많고 사용하기에 약간 엄격할 수 있습니다. 미래에는 이것을 제거하는 것을 고려할 것 같습니다, 그러나 centrain 엔진 시스템은 스레드 인덱스로 CommandList를 사용하도록 다시 쓰여졌을 것입니다.
Starting GPU work
GraphicsDevice::SubmitCommandLists() 함수는 GPU 실행을 위해 간단히 지금까지 사용되고 제출된 순서로 CommandList를 닫습니다(BeginCommandList()가 호출된 순서와 같은 순서). 이것은 네이티브 그래픽스 API가 제공하는 것보다 고도로 단순화된 인터페이스입니다. 이것은 제한사항으로 볼 수도 있지만 이것은 또한 렌더러를 어떻게 사용해야 하는지에 대한 가이드라인입니다, 왜냐하면 이것은 여러 개의 커맨드 리스트를 단일 제출 연산으로 배칭 하기에 일반적으로 좋은 아이디어입니다. 배칭은 더 효과적인 드라이버 패스를 얻게 해 줍니다.
게다가, SubmitCommandLists()은 swapchains이 제출되는 곳입니다. 그리고 버퍼들이 동기화되고 교체(swapped) 되는 곳입니다. 이 곳이 GPU에 의해 사용되어지는 리소스가 해제되고 CPU로부터 수정될 수 있기까지(커맨드 버퍼 같은) 기다리는 곳입니다. 잠재적으로 CPU가 즉시 멈추는 것을 방지하기 위해서 분리된 스레드에서 제출이 수행될 수 있습니다, 그리고 다음 프레임의 렌더링과 관련 없는 로직 준비합니다.
제출이 오직 특정 커맨드 리스트만 되도록 더 세밀한 버젼의 제출이 미래에 만들어질 수 있습니다. 이것은 몇몇 커맨드리스트가 현재 프레임이 묶이지 않고 관련 없는 작업을 하는 경우 이득이 있습니다. 그것은 프레임과 더 느슨하게 연관된 제출을 얻는 유일한 방법이 아닙니다. CreateTexture()와 CreateBuffer()는 작은 작업을 copy queue에 제출하기 위해 모든 스레드에서 비동기적으로(데이터 리소스를 초기화하는 데 사용) 사용될 수 있는 특별한 함수입니다.
Async compute
이러한 단순화된 제출 모델이더라도, async compute 구성은 가능합니다. async compute 모델은 이 모델에 새로 추가된 것입니다, 왜냐하면 이전 graphics API는 이런 가능성이 없었기 때문입니다. 심지어, 인터페이스는 직관적이고 거추장스럽지 않다는 것이 내 의견입니다. BeginCommandList() 함수는 선택 가능한 파라메터가 있습니다, 그것은 특정 GPU_QUEUE를 지정하는 데 사용할 수 있습니다, 기본적으로, QUEUE_GRAPHICS가 사용될 것입니다, 여기에는 모든 타입의 커맨드가 사용될 수 있습니다. 만약 QUEUE_COMPUTE가 사용된다면, 커맨드는 compute jobs을 위해 분리된 GPU queue 나 타임라인에서 실행될 것입니다. 이것들이 graphics queue jobs과 같은 시간에 스케쥴링될 수 있습니다. SubmitCommandList() 구현은 Queue와 일치하는 커맨드 버퍼의 API 특성을 자동으로 처리합니다. Queue사이의 동기화는 GraphicsDevice::WaitCommandList() 커맨드로 수행됩니다. 이 명령은 분리된 큐에 있는 커맨드리스트 간 종속성을 지정하는 데 사용됩니다. DX12와 Vulkan API는 분리된 제출 사이에 Queue만 동기화 가능합니다, 그래서 구현에서 필요한 경우 커맨드리스트를 여러개의 제출로 자동으로 나눕니다. 같은 Queue에 있는 종속성은 다릅니다, 이것들은 GPU Barrier를 통해 처리됩니다. DX11의 경우, 다른 Queue를 가지지 않습니다, 구현은 Queue 파라메터를 간단히 무시하고 모든 것이 main queue에서 실행되게 할 수 있습니다, 결과는 같을 것입니다, 단지 몇몇 최적화 기회를 잃을 것입니다.
async compute 를 사용하는 예제:
CommandList cmd0 = device->BeginCommandList(QUEUE_GRAPHICS); CommandList cmd1 = device->BeginCommandList(QUEUE_COMPUTE); device->WaitCommandList(cmd1, cmd0); // cmd1 waits for cmd0 to finish CommandList cmd2 = device->BeginCommandList(QUEUE_GRAPHICS); // cmd2 doesn't wait, it runs async with cmd1 CommandList cmd3 = device->BeginCommandList(QUEUE_GRAPHICS); device->WaitCommandList(cmd3, cmd1); // cmd3 waits for cmd1 to finish device->SubmitCommandLists(); // execute all of the above by the GPU
GPU Barries
베리어는 DX12나 Vulkan 같은 현재 PC Graphics API 에서 소개되었으며 이 인터페이스에 합리적인 범위로 노출되었습니다. 아무튼, 개발자에게 이런 컨트롤 수준을 주는 명백한 이유는 최적화된 성능을 얻기 위해서입니다. DX11은 이러한 개념이 없습니다, 그래서 API에 대한 GraphicsDevice::Barrier() 커맨드의 구현은 비어있습니다. 추상화를 사용하여, DX11에서 올바르게 구현하는 것은 DX12와 Vulkan에서 올바르게 동작하는 것을 보장할 수 없을 것입니다, 그러나 그 반대의 경우는 가능합니다. 베리어는 DX12의[UAV 그리고 Transition] 베리어와 Vulkan의[Pipeline] 베리어의 혼합입니다. 앨리어싱 베리어는 간단히 하기 위해 지금은 구현되지 않았습니다. 왜냐하면 내가 아직 그것을 해보지 않았기 때문입니다. GPUBarrier 구조체는 간단한 구조체 타입니다. 그것은 API 특성 데이터를 가지지 않습니다. GPUBarrier는 다음과 같습니다:
- 메모리 베리어(Memory barrier): DX12의 UAV 베리어로 불림, 이것은 리소스가 기록하거나 쉐이더가 끝날 때까지 기다리는 데 사용됩니다. GPUBarrier::Memory() 함수는 간단히 이런 베리어를 선언하기 위해서 사용할 수 있습니다. 만약 리소스가 아규먼트에 제공되지 않는다면, 모든 이전 GPU 작업은 반드시 끝나야 합니다.
- 버퍼 베리어(Buffer barrier): 서로 다른 BUFFER_STATE의 GPUBuffer 전환에 사용됩니다.
- 이미지 베리어(Image barrier): 서로 다른 IMAGE_LAYOUT의 Texture 전환에 사용됩니다.
GraphcisDevice::Barrier() 함수는 베리어 커맨드의 배칭을 설정하여 커맨드리스트에 설정할 수 있습니다. 두 베리어의 세팅의 예제:
GPUBarrier barriers[] = { GPUBarrier::Memory(), GPUBarrier::Image(&texture, IMAGE_LAYOUT_UNORDERED_ACCESS, texture.desc.layout) }; device->Barrier(barriers, arraysize(barriers), cmd);
메모리 베리어는 compute shader가 끝나기를 기다리는 데 사용합니다, 이미지 베리어는 텍스쳐를 unordered access layout 에서 기본 layout 으로 전환합니다.
[DX12, Vulkan] 두 가지 경우 구현은 베리어를 즉시 제출하지 않습니다, 가능한때에 그들을 추가로 처리하거나 지연시킬 수 있는 기회가 있습니다. 일부 그래픽 효과를 위해서 엔진에서 두 개의 분리된 함수가 있는 경우를 고려해봅시다. 이들은 관련이 없을 수 있습니다, 그리고 서로를 모를 수 있습니다, 그래서 그들은 중복된 베리어 커맨드를 같은 커맨드리스트에 제출합니다. 엔진이 서로 독립적으로 수행되는 고수준 그래픽스 helper 함수를 제공하는 것은 큰 이익이 될 수 있습니다, 그러나 하위 API 특성 수준에서, 중복된 커맨드를 제거하기 위해 더 최적화를 진행할 수 있습니다. 이런 지연은 구현의 다른 곳에서도 발생합니다, 여러 개의 파이프라인 스테이트를 그리지 않고 설정할 때나 여러 리소스를 슬롯에 묶을 때와 같은 상황.
추상화의 유용한 특성은 텍스쳐와 버퍼를 위한 시작 layout을 설정할 수 있는 것입니다, 그래서 API가 이것을 전환할 수 있습니다. 또한 엔진이 리소스 레이아웃의 시작을 언제나 예측할 수 있게 하는 것은 유용합니다. 고수준 그래픽스 코드는 필요한 경우 대부분의 시간을 기본 레이아웃에서 임시 레이아웃으로 전환되어야만 합니다. 그리고 다시 기본 레이아웃으로 돌립니다. 기본 레이아웃은 성능과 편의성의 이유로 대부분은 공통적으로 사용되는 리소스 레이아웃을 선택해야만 합니다. 리소스 생성전에 이것을 선언하지 않고, 기본 레이아웃은 읽기 전용 상태로 가정합니다. 심지어 2개의 연속적인 고수준 그래픽스 함수가 특정 리소스의 레이아웃을 빠르게 앞뒤로 전환하는 경우에도, 이러한 전환 중 일부는 API 레이어에 의해서 중복된 것으로 밝혀질 수 있습니다. 그리고 위에 설명대로 제거됩니다.
Render Passes
DirectX는 SetRendertargets를 사용합니다, Vulkan은 render passes를 사용합니다. SetRendertargets를 Vulkan render pass의 구현으로 할 수 있을 것입니다, 그래서 처음에는 이 방법이 선택되었습니다. 그러나 다른 방법으로 (render pass를 Setrendertargets로 구현)하는 것이 더 쉽습니다, 그래서 시간이 지나면서 렌더 패스를 요구하도록 변경되었습니다. 반드시 생성돼야 할 GPU 리소스가 있습니다, 그러나 텍스쳐 레이아웃 전환 선언과 렌더링 내용 보존 여부 결정과 같은 더 많은 기능을 허용합니다. DX12는 또한 render pass 를 기본적으로 지원합니다, 그게 이것을 사용하는 또 다른 이유입니다. 내 경험에서 약간 딱딱한 그래픽스 프로그래밍 모델을 강요합니다, 그러나 그것은 개발자가 덜 실수하게 만듭니다, 렌더타겟 설정/해제와 같은. 가끔은 렌더 타겟의 내용이 완전히 임시적인 내용일 수 있습니다, 읽히거나 사용되지 않는 싱글 렌더패스의 깊이 버퍼 같은 것 - 이것은 GPU memory에 기록될 필요가 없습니다, 그것은 일부 GPU 하드웨어에서는 전부 타일캐시(tilecache) 에 있을 수 있습니다, 이제 우리는 이러한 의도를 선언할 방법이 있습니다.
렌더패스를 생성하고 사용하는 예제:
RenderPassDesc desc;
desc.attachments.push_back( // add a depth render target
RenderPassAttachment::DepthStencil(
&depthBuffer_Main,
RenderPassAttachment::LOADOP_CLEAR, // clear the depth buffer
RenderPassAttachment::STOREOP_STORE, // preserve the contents of depth buffer
IMAGE_LAYOUT_DEPTHSTENCIL_READONLY, // initial layout
IMAGE_LAYOUT_DEPTHSTENCIL, // renderpass layout
IMAGE_LAYOUT_SHADER_RESOURCE // final layout
)
);
desc.attachments.push_back( // add a color render target
RenderPassAttachment::RenderTarget(
&gbuffer[GBUFFER_VELOCITY],
RenderPassAttachment::LOADOP_DONTCARE // texture contents are undefined at start
// rest of parameters are defaults
)
);
if (depthBuffer_Main.desc.SampleCount > 1)
{
// if MSAA rendering, then add a resolve pass for color render target:
desc.attachments.push_back(
RenderPassAttachment::Resolve(gbuffer_resolved[GBUFFER_VELOCITY])
);
}
device->CreateRenderPass(&desc, &renderpass_depthprepass);
device->RenderPassBegin(&renderpass_depthprepass, cmd);
// render into texture...
device->RenderPassEnd(cmd);
Pipeline States
DX11은 여러 파이프라인 스테이지를 위해 분리된 스테이트 오브젝트를 가집니다, 이것은 DX12와 Vulkan 방식과 다릅니다. 이 것은 결합된 PipelineState 오브젝트 노출하도록 하는 것이 가장 쉬웠습니다. 그리고 DX11 구현은 파이프라인 스테이트를 장면 뒤의 분리된 부분에서 만들고 반면에 DX12와 Vulkan의 구현은 그들의 기본 방식으로 동작하게 만들었습니다. 적어도 처음에는 이런 방법이었습니다, 그러나 렌더타겟의 포맷을 선언, 모든 PipelinState를 위한 샘플 숫자와 렌더패스는 그래픽스 프로그래머의 관점에서 불편해졌습니다. 이제 인터페이스는 프로그래머가 생성전에 선언할 필요 없이 어떤 렌더패스를 사용하느냐에 따라 동적으로 PipelinState 오브젝트를 컴파일하게 지원됩니다. 대부분의 파이프라인 스테이트는 여전히 선언됩니다만 개별 상태 설정을 잊는것을 피하는데 아주 유용합니다, 그리고 나는 이것이 그래픽스 프로그래밍의 실수를 아주 많이 줄여준다는 것을 알았습니다. 구현은 여전히 파이프라인에 대해 가능한 빠르게 수많은 바쁜 작업을 합니다. 이것은 생성시점에 모든 가능한 상태의 쉐이더 리플렉션과 해싱을 기반으로 한 결정을 포함합니다. 이 방식은 런타임 상태 해시가 최소화됩니다, 오직 pipeline_state_has x render_pass_has 가 계산됩니다, 이것은 현재 렌더패스를 위해 이미 컴파일된 PSO가 있는지 또는 컴파일이 필요한지 판단하는데 사용됩니다. PSO 컴파일은 커맨드리스트를 기록하는 스레드에서 일어납니다, 엔진이 렌더링패스에서 여러 스레드를 사용하기 때문에 어떤 면에서는 멀티스레드입니다.
개선으로, 나는 여전히 렌더패스를 미리 지정하게 하는 옵션을 고려하고 있습니다, 그러나 옵션으로, 왜냐하면 몇몇 하드 코딩된 렌더링 효과에 유용할 수 있기 때문입니다, 반면에 렌더러가 현재 렌더패스의 지식을 사용하지 않고 디자인된 곳에서는 방해가 되는 점을 염두해야 합니다.
Resource binding
리소스를 쉐이더에 제공하는 가장 일반적인 방법은 쉐이더 쪽에서 슬롯 번호를 선언하는 것입니다, 그리고 리소스를 그 슬롯 번호에 맞게 어플리케이션 쪽에 묶습니다. 이것은 DX11에서만 작동합니다, 반면에 DX12와 Vulkan은 이 동작을 에뮬레이트 할 수 있습니다, 비록 꽤 다른 방식이지만요. DX12에서는, global shader visible descptor heap (하나는 샘플러를 위해, 하나는 리소스를 위해)이 ring 버퍼로 관리되며, 쉐이더를 사용하는 모든 descriptor는 스테이징 descriptor heap으로부터 복사됨으로써 할당되고 채워집니다. DX12에서는 descriptor heap을 한 프레임에 한번 이상 묶는 것을 피하기를 아주 많이 권장합니다, 그래서 큰 것 한 개가 사용됩니다, 그것은 tier1 제한을 가질 수 있습니다(백만 CBV_SRV_UAV 그리고 2048 샘플러). 전형적인 Vulkan은 아주 다릅니다, 스레드마다 매번 descriptor 레이아웃이 변경되거나 바인딩 변경으로 무효화될 때마다 분리된 descriptor pool로부터 vkAllocateDesciptorSets()를 사용합니다. Vulkan 구현은 만약 그들이 오래된 것을 삭제하고 더 큰 세트를 할당하여 세트가 부족한 경우 동적으로 descriptor pool이 증가합니다. 비록 구현이 아주 많이 다르고 구현에 어려움이 있지만 그들은 DX11과 유사한 인터페이스 모델에 잘 맞습니다.
이 예제에서는, 나는 이전 예제에서 만든 서브리소스를 가진 텍스쳐의 mip level 5를 묶을 것입니다, mip당 한 개의 서브리소스가 연속적으로 있으므로, 우리는 암묵적으로 서브리소스의 인덱스를 알고 있습니다:
device->BindResource(PS, &texture, 26, cmd, 5); // binds subresource 5 to pixel shader slot 26
또는 전체 리소스를 묶음:
device->BindResource(PS, &texture, 26, cmd);
DX11에서 개인적으로 한 가지 불편한 것은 UAV를 묶으면 텍스쳐 SRV가 자동으로 묶음 해지가 되는 것입니다, 이것은 과도한 디버깅 경과나 원치 않은 동작 (Vulkan과 DX12는 이러한 제한이 없음)을 유발합니다. 현재는 그래픽스 코드 개발에 이것을 반드시 고려해야 합니다.
Bindless deacriptors
Wicked Engine의 이전 블로그의 묶을 필요 없는 descriptor에 대해서 이야기하였습니다. 자세한 것은 여기에 https://wickedengine.net/2021/04/06/bindless-descriptors/
요약하자면, 그들은 DX12와 Vulkan을 지원합니다, 반면에 DX11은 지원하지 않습니다. GraphicsDevice 인터페이스는 이 기능이 현재 사용될지 말지를 결정하는 방법을 제공합니다. 아래 코드를 호출하여:
bool GraphicsDevice::CheckCapability(GRAPHICSDEVICE_CAPABILITY_BINDLESS_DESCRIPTORS);
Shaders
GraphicsDevice 관점에서, 쉐이더는 컴파일된 바이너리 데이터 blob을 GraphicsDevice::CreateShader() 함수로 제공받습니다. DX11 구현은 쉐이더 모델 5.0 HLSL 포맷까지 지원할 것 것입니다, DX12는 쉐이더 모델 6.0이나 이상의 HLSL 포맷을 지원합니다, 반면에 Vulkan은 SPIRV 포맷을 필요로 합니다. 쉐이더는 특정 툴로 미리 컴파일될 수 있습니다, 기본 쉐이더 포맷이 허용됩니다. DX12와 Vulkan은 최적의 파이프라인 레이아웃을 위해 쉐이더 리플렉션이 사용되도록 만들어야 합니다, 그래서 이런 경우 리플렉션 데이터를 제거해서는 안됩니다. 게다가, Wicked Engine은 DX11, DX12 그리고 Vulkan 에서 사용되는 HLSL로 작성된 쉐이더를 컴파일할 수 있는 쉐이더 컴파일러 인터페이스를 제공합니다. 이 툴은 기본 쉐이더 컴파일러인 d3dcompiler 나 dxcompiler DLL을 구동합니다. 흥미로운 특징으로, 이 툴은 엔진의 모든 쉐이더를 컴파일하여 C++ 해더 파일로 만드는 것을 지원합니다, exe에 쉐이더를 내장하기 위해서, 그래서 엔진은 모든 쉐이더에 이하 추가적인 파일 로딩 연산을 할 필요가 없습니다. 기본 방법은 모든 쉐이더를 쉐이더 바이너리를 포함한 분리된 .cso 파일로 컴파일하는 것입니다. 이것은 또한 쉐이더 핫리로딩(쉐이더 코드가 변화면, 엔진이 그것을 감지하고, 재컴파일하고 재로드 함).
Mipmap generation
DX11 GenerateMips 호출을 제공합니다, 이것은 API 호출 한 번으로 전체 텍스쳐의 전체 mipchain을 생성하는 데 사용합니다. DX12와 Vulkan에는 해당하지 않습니다, 어플리케이션은 이 기능을 쉐이더를 사용하여 구현할 것으로 예상합니다. 이게 말이 되는 게, mip level을 생성하는 것은 컨텐츠나 아트 디렉션에 종속될 수 있기 때문입니다. 예를 들어 Wicked Engine은 다양한 필터와 알파 바이어싱으로 너무 많은 알파테스트를 저수준의 디테일에서 방지하도록 mip level을 생성할 수 있습니다(http://the-witness.net/news/2010/09/computing-alpha-mipmaps/).
GraphicsDevice는 GenerateMips 기능을 노출하지 않습니다, 그리고 쉐이더에서 구현되었기를 기대합니다. 이것은 GraphicsDevice의 구현을 줄여줍니다. 이것은 관리를 더 쉽게 해 주기 때문에 좋습니다. 보통 나는 고수준에서 구현할 수 있을 것을 GraphicsDevice로부터 어떤 것이든 노출하지 않는 것을 좋아합니다, 예를 들면 버퍼에서 텍스쳐로 복사하는 것, 원하는 텍스쳐 영역을 복사하는 것. 엔진은 이미 이러한 쉐이더를 사용하도록 구현되어있습니다, 쉐이더가 크기를 조정하고, 텍스쳐를 필터링하고, 텍스쳐 형식을 변환하고, 테두리 패딩을 할 수 있기 때문에 의미가 있습니다.
Future
Wicked Engine의 그래픽 인터페이스는 이전의 여러 반복의 결과이고 여전히 발전하고 있습니다. 이것은 장난감으로만 개발되는 것은 아니며, 이미 많은 양의 그래픽 효과가 쓰여있습니다. 나는 또한 이것이 다른 사람들을 위한 가이드나 학습자료가 될 수 있다고 생각합니다. 만약 추상 시스템을 모두가 좋아하지 않는다면, 저수준 API 코드 또한 역시 그대로 사용 가능하며 그들이 담겨있는 분리된 파일에 쉽게 접근할 수 있습니다(그래픽스 API당 오직 두 개의 파일: .h 그리고 .cpp).
- Graphics resources [header]
- GraphicsDevice_DX11 [header] [implementation]
- GraphicsDevice_DX12 [header] [implementation]
- GraphicsDevice_Vulkan [header] [implementation]
DX11을 항상 지원하는 것은 의미 없을 것입니다. 지금도 계속 운영하는 것은 이점이 있다고 봅니다, 이것을 사용하는 것은 DX11에서 그래픽 효과를 쉽게 가져오고 테스트할 수 있는 방법일 수 있습니다, 그리고 이후에 Vulkan과 DX12를 위해 예를 들어 베리어를 추가함으로써 수정과 최적화를 추가합니다. 이것은 미래에 변경될 수 있으며 DX11은 지워질 수 있습니다. 이것은 확실히 일부 API의 제한을 풀어줄 것입니다.
읽어주셔서 감사합니다! 만약 당신이 댓글을 달고 싶다면 아래에 달아주세요. 그래픽 인터페이스에 대한 더 완벽한 글을 읽으려면, Wicked Engine Documentation’s Graphics chapter 를 방문하세요(이것은 블로그와 독립적으로 갱신되고 미래에는 달라질 것입니다)
'Graphics > 참고자료' 카테고리의 다른 글
[번역] Visibility Buffer Rendering with Material Graphs – Filmic Worlds (2) | 2021.10.15 |
---|---|
[번역] Temporal Anti-Aliasing(TAA) Tutorial (0) | 2021.06.24 |
[번역] How to read shader assembly – Interplay of Light (0) | 2021.04.24 |
[번역] Implementing FXAA (0) | 2021.02.17 |
[번역] Screen Space Reflections : Implementation and optimization – Part 2 (0) | 2021.01.30 |