剖析虚幻渲染系统 (13) - RHI 补充:现代图形 API 指南
-
13.1 本篇概述
- 13.1.1 本篇内容
- 13.1.2 概念总览
- 13.1.3 现代图形API特点
-
13.2 设备上下文
- 13.2.1 启动流程
- 13.2.2 Device
- 13.2.3 Swapchain
-
13.3 管线资源
- 13.3.1 Command
- 13.3.2 Render Pass
- 13.3.3 Texture, Shader
- 13.3.4 Shader Binding
- 13.3.5 Heap, Buffer
- 13.3.6 Fence, Barrier, Semaphore
-
13.4 管线机制
-
13.4.1 Resource Management
- 13.4.1.1 Resource Allocation
- 13.4.1.2 Resource Update
- 13.4.2 Pipeline State Object
-
13.4.3 Synchronization
- 13.4.3.1 Barrier
- 13.4.3.2 Fence
- 13.4.3.3 Pipeline Barrier
- 13.4.4 Parallel Command Recording
- 13.4.5 Multi Queue
-
13.4.6 其它管线技术
- 13.4.6.1 Wave
- 13.4.6.2 ExecuteIndirect
- 13.4.6.3 Predication
- 13.4.6.4 UAV Overlap
- 13.4.6.5 Multi GPU
-
13.4.1 Resource Management
-
13.5 综合应用
- 13.5.1 Rendering Hardware Interface
- 13.5.2 Multithreaded Rendering
- 13.5.3 Frame Graph
- 13.5.4 GPU-Driven Rendering Pipeline
- 13.5.5 Performance Monitor
-
13.6 本篇总结
- 13.6.1 Vulkan贡献者名单
- 13.6.2 本篇思考
- 特别说明
- 参考文献
13.1 本篇概述
13.1.1 本篇内容
本篇是RHI篇章的补充篇,将详细且深入地阐述现代图形API的特点、原理、机制和优化技巧。更具体地,本篇主要阐述以下内容:
- 现代图形API的基础概念。
- 现代图形API的特性。
- 现代图形API的使用方式。
- 现代图形API的原理和机制。
- 现代图形API的优化建议。
此文所述的现代图形API指DirectX12、Vulkan、Metal等,而不包含DirectX11和Open GL(ES),但也不完全排除后者的内容。
由于UE的RHI封装以DirectX为主,所以此文也以DirectX作为主视角,Vulkan、Metal等作为辅视角。
13.1.2 概念总览
我们都知道,现存的API有很多种(下表),它们各具特点,自成体系,涉及了众多不同但又相似的概念。
图形API | 适用系统 | 着色语言 |
---|---|---|
DirectX | Windows、XBox | HLSL(High Level Shading Language) |
Vulkan | 跨平台 | SPIR-V |
Metal | iOS、MacOS | MSL(Metal Shading Language) |
OpenGL | 跨平台 | GLSL(OpenGL Shading Language) |
OpenGL ES | 移动端 | ES GLSL |
下面是它们涉及的概念和名词的对照表:
DirectX | Vulkan | OpenGL(ES) | Metal |
---|---|---|---|
texture | image | texture and render buffer | texture |
render target | color attachments | color attachments | color attachments or render target |
command list | command buffer | part of context, display list, NV_command_list | command buffer |
command list | secondary command buffer | - | parallel command encoder |
command list bundle | - | light-weight display list | indirect command buffer |
command allocator | command pool | part of context | command queue |
command queue | queue | part of context | command queue |
copy queue | transfer queue | glBlitFramebuffer() | blit command encoder |
copy engine | transfer engine | - | blit engine |
predication | conditional rendering | conditional rendering | - |
depth / stencil view | depth / stencil attachment | depth attachment and stencil attachment | depth attachment and stencil attachment, depth render target and stencil render target |
render target view, depth / stencil view, shader resource view, unordered access view | image view | texture view | texture view |
typed buffer SRV, typed buffer UAV | buffer view, texel buffer | texture buffer | texture buffer |
constant buffer views (CBV) | uniform buffer | uniform buffer | buffer in constant address space |
rasterizer order view (ROV) | fragment shader interlock | GL_ARB_fragment_shader_interlock | raster order group |
raw or structured buffer UAV | storage buffer | shader storage buffer | buffer in device address space |
descriptor | descriptor | - | argument |
descriptor heap | descriptor pool | - | heap |
descriptor table | descriptor set | - | argument buffer |
heap | device memory | - | placement heap |
- | subpass | pixel local storage | programmable blending |
split barrier | event | - | - |
ID3D12Fence::SetEventOnCompletion | fence | fence, sync | completed handler, -[MTLComandBuffer waitUntilComplete] |
resource barrier | pipeline barrier, memory barrier | texture barrier, memory barrier | texture barrier, memory barrier |
fence | semaphore | fence, sync | fence, event |
D3D12 fence | timeline semaphore | - | event |
pixel shader | fragment shader | fragment shader | fragment shader or fragment function |
hull shader | tessellation control shader | tessellation control shader | tessellation compute kernel |
domain shader | tessellation evaluation shader | tessellation evaluation shader | post-tessellation vertex shader |
collection of resources | fragmentbuffer | fragment object | - |
pool | heap | - | - |
heap type, CPU page property | memory type | automatically managerd, texture storage hint, buffer storage | storage mode, CPU cache mode |
GPU virtual address | buffer device address | - | - |
image layout, swizzle | image tiling | - | - |
matching semantics | interface matching (in / out) | varying (removed in GLSL 4.20) | - |
thread, lane | invocation | invocation | thread, lane |
threadgroup | workgroup | workgroup | threadgroup |
wave, wavefront | subgroup | subgroup | SIMD-group, quadgroup |
slice | layer | - | slice |
device | logical device | context | device |
multi-adapter device | device group | implicit(E.g. SLICrossFire) | peer group |
adapter, node | physical device | - | device |
view instancing | multiview rendering | multiview rendering | vertex amplification |
resource state | image layout | - | - |
pipeline state | pipeline | stage and program or program pipeline | pipeline state |
root signature | pipeline layout | - | - |
root parameter | descriptor set layout binding, push descriptor | - | argument in shader parameter list |
resulting ID3DBlob from D3DCompileFromFile | shader module | shader object | shader library |
shading rate image | shading rate attachment | - | rasterization rate map |
tile | sparse block | sparse block | sparse tile |
reserved resource(D12), tiled resource(D11) | sparse image | sparse texture | sparse texture |
window | surface | HDC, GLXDrawable, EGLSurface | layer |
swapchain | swapchain | Pairt of HDC, GLXDrawable, EGLSurface | layer |
- | swapchain image | default framebuffer | drawable texture |
stream-out | transform feedback | transform feedback | - |
从上表可知,Vulkan和OpenGL(ES)比较相似,但多了很多概念。Metal作为后起之秀,很多概念和DirectX相同,但部分又和Vulkan相同,相当于是前辈们的混合体。
对于Vulkan,涉及的概念、层级和数据交互关系如下图所示:
Vulkan概念和层级架构图。涉及了Instance、PhysicalDevice、Device等层级,每个层级的各个概念或资源之间存在错综复杂的引用、组合、转换、交互等关系。
Metal资源和概念框架图。
13.1.3 现代图形API特点
对于传统图形API(DirectX11及更早、OpenGL、OpenGL ES),GPU编程开销很大,主要表现在:
- 状态校验(State validation):
- 确认API标记和数据合法。
- 编码API状态到硬件状态。
- 着色器编译(Shader compilation):
- 运行时生成着色器机器码。
- 状态和着色器之间的交互。
- 发送工作到GPU(Sending work to GPU):
- 管理资源生命周期。
- 批处理渲染命令。
对于以上开销大的操作,传统图形API和现图形代API的描述如下:
阶段 | 频率 | 传统图形API | 现代图形API |
---|---|---|---|
应用程序构建 | 一次 | - | 着色器编译 |
内容加载 | 少次 | - | 状态校验 |
绘制调用 | 1000次每帧 | 状态校验,着色器编译,发送工作到GPU | 发送工作到GPU |
以上可知,传统API将开销较大的状态校验、着色器编译和发送工作到GPU全部放到了运行时,而现代图形API将着色器编译放到了应用程序构建期间,而状态校验移至内容加载之时,只保留发送工作到GPU在绘制调用期间,从而极大减轻了运行时的工作负担。
现代图形API(DirectX12、Vulkan、Metal)和传统图形API的描述对照表如下:
现代图形API | 传统图形API |
---|---|
基于对象的状态,没有全局状态。 | 单一的全局状态机。 |
所有的状态概念都放置到命令缓冲区中。 | 状态被绑定到单个上下文。 |
可以多线程编码,并且受驱动和硬件支持。 | 渲染操作只能被顺序执行。 |
可以精确、显式地操控GPU的内存和同步。 | GPU的内存和同步细节通常被驱动程序隐藏起来。 |
驱动程序没有运行时错误检测,但存在针对开发人员的验证层。 | 广泛的运行时错误检测。 |
相比OpenGL(ES)等传统API,Vulkan支持多线程,轻量化驱动层,可以精确地管控GPU内存、同步等资源,避免运行时创建和消耗资源堆,避免运行时校验,避免CPU和GPU的同步点,基于命令队列的机制,没有全局状态等等(下图)。
Vulkan拥有更轻量的驱动层,使得应用程序能够拥有更大的*度控制GPU,也有更多的硬件性能。
图形API、驱动层、操作系统、内核层架构图。
Metal(右)比OpenGL(左)拥有更轻量的驱动层。
DirectX11驱动程序(上)和DirectX12应用程序(下)执行的工作对比图。
得益于Vulkan的先进设计理念,使得它的渲染性能更高,通常在CPU、GPU、带宽、能耗等指标都优于OpenGL。但如果是应用程序本身的CPU或者GPU负载高,则使用Vulkan的收益可能没有那么明显:
对于使用了传统API的渲染引擎,如果要迁移到现代图形API,潜在收益和工作量如下图所示:
从OpenGL(ES)迁移到现代图形API的成本和收益对比。横坐标是从OpenGL(ES)迁移其它图形API的工作量,纵坐标是潜在的性能收益。可见Vulkan和DirectX12的潜在收益比和工作量都高,而Metal次之。
部分GPU厂商(如NVidia)会共享OpenGL和Vulkan驱动,甚至在应用程序层,它们可以混合:
NV的OpenGL和Vulkan共享架构图。可以共享资源、工具箱,提升性能,提升可移植性,允许应用程序在最重要的地方增加Vulkan,获取了OpenGL即获取了Vulkan,减少驱动程序的开发工作量。
利用现代图形API,可以获得的潜在收益有:
- 更好地利用多核CPU。如多线程录制、多线程渲染、多队列、异步技术等。
- 更小的驱动层开销。
- 精确的内存和资源管理。
- 提供精确的多设备访问。
- 更多的Draw Call,更多的渲染细节。
- 更高的最小、最大、平均帧率。
- 更高效的GPU硬件使用。
- 更高效的集成GPU硬件使用。
- 降低系统功率。
- 允许新的架构设计,以前由于传统API的技术限制而认为是不可能的,如TBR。
13.2 设备上下文
13.2.1 启动流程
对大多数图形API而言,应用程序使用它们时都存在以下几个阶段:
- InitAPI:创建访问API内部工作所需的核心数据结构。
- LoadingAssets:创建数据结构需要加载的东西(如着色器),以描述图形管道,创建和填充命令缓冲区让GPU执行,并将资源发送到GPU的专用内存。
- UpdatingAssets:更新任何Uniform数据到着色器,执行应用程序级别的逻辑。
- Presentation:将命令缓冲区列表发送到命令队列,并呈现交换链。
- AppClosed:如果应用程序没有发送关闭命令,则重复LoadingAssets、UpdatingAssets、Presentation阶段,否则执行Destroy阶段。
- Destroy:等待GPU完成所有剩余工作,并销毁所有数据结构和句柄。
现代图形API启动流程。
后续章节将按照上面的步骤和阶段涉及的概念和机制进行阐述。
13.2.2 Device
初始化图形API阶段,涉及了Factory、Instance、Device等等概念,它们的概念在各个图形API的对照表如下:
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Entry Point | FDynamicRHI | IDXGIFactory4 | IDXGIFactory | vk::Instance | CAMetalLayer | Varies by OS |
Physical Device | - | IDXGIAdapter1 | IDXGIAdapter | vk::PhysicalDevice | MTLDevice | glGetString(GL_VENDOR) |
Logical Device | - | ID3D12Device | ID3D11Device | vk::Device | MTLDevice | - |
Entry Point(入口点)是应用程序的全局实例,通常一个应用程序只有一个入口点实例。用来保存全局数据、配置和状态。
Physical Device(物理设备)对应着硬件设备(显卡1、显卡2、集成显卡),可以查询重要的设备具体细节,如内存大小和特性支持。
Logical Device(逻辑设备)可以访问API的核心内部函数,比如创建纹理、缓冲区、队列、管道等图形数据结构,这种类型的数据结构在所有现代图形api中大部分是相同的,它们之间的变化很少。Vulkan和DirectX 12通过Logical Device创建内存数据结构来控制内存。
每个应用程序通常有且只有一个Entry Point,UE的Entry Point是FDynamicRHI的子类。每个Entry Point拥有1个或多个Physical Device,每个Physical Device拥有1个或多个Logical Device。
13.2.3 Swapchain
应用程序的后缓存和交换链根据不同的系统或图形API有所不同,涉及了以下概念:
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Window Surface | FRHIRenderTargetView | ID3D12Resource | ID3D11Texture2D | vk::Surface | CAMetalLayer | Varies by OS |
Swapchain | - | IDXGISwapChain3 | IDXGISwapChain | vk::Swapchain | CAMetalDrawable | Varies by OS |
Frame Buffer | FRHIRenderTargetView | ID3D12Resource | ID3D11RenderTargetView | vk::Framebuffer | MTLRenderPassDescriptor | GLuint |
在DirectX上,由于只有Windows / Xbox作为API的目标,最接近Surface(表面)的东西是从交换链接收到的纹理返回缓冲区。交换链接收窗口句柄,从那里DirectX驱动程序内部会创建一个Surface。对于Vulkan,需要以下几个步骤创建可呈现的窗口表面:
Vulkan WSI的步骤示意图。
由于MacOS和iOS窗口具有分层结构(hierarchical structure),其中应用程序包含一个视图(View),视图可以包含一个层(layer),在Metal中最接近Surface的东西是layer或包裹它的view。
Metal和OpenGL缺少交换链的概念,而把交换链留给了操作系统的窗口API。
DirectX 12和11没有明确的数据结构表明Frame Buffer,最接近的是Render Target View。
Swapchain(交换链)包含单缓冲、双缓冲、三缓冲,分别应对不同的情况。应用程序必须做显式的缓冲区旋转:
DirectX:IDXGISwapChain3::GetCurrentBackBufferIndex()
下面是对Swapchain的使用建议:
- 如果应用程序总是比vsync运行得慢,那么在交换链中使用1个Surface。
- 如果应用程序总是比vsync运行得快,那么在交换链中使用2个Surface,可以减少内存消耗。
- 如果应用程序有时比vsync运行得慢,那么在交换链中使用3个Surface,可以给应用程序提供最佳性能。
Vulkan交换链运行示意图。
13.3 管线资源
现代图形渲染管线涉及了复杂的流程、概念、资源、引用和数据流关系。(下图)
Vulkan渲染管线关系图。
13.3.1 Command
现代图形API的Command(命令)包含应用程序向GPU交互的所有操作,涉及了以下几种概念:
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Command Queue | - | ID3D12CommandQueue | ID3D11DeviceContext | vk::Queue | MTLCommandQueue | - |
Command Allocator | - | ID3D12CommandAllocator | ID3D11DeviceContext | vk::CommandPool | MTLCommandQueue | - |
Command Buffer | FRHICommandList | ID3D12GraphicsCommandList | ID3D11DeviceContext | vk::CommandBuffer | MTLRenderCommandEncoder | - |
Command List | FRHICommandList | ID3D12CommandList[] | ID3D11CommandList | vk::SubmitInfo | MTLCommandBuffer | - |
Command Queue允许我们将任务加入队列给GPU执行。GPU是一种异步计算设备,需要让它一直处于繁忙状态,同时控制何时将项目添加到队列中。
Command Allocator允许创建Command Buffer,可以定义想要GPU执行的函数。Command Allocator数量上的建议是:
如果有数百个Command Allocator,是错误的做法。Command Allocator只会增加,意味着:
- 不能从分配器中回收内存。回收分配器将把它们增加到最坏情况下的大小。
- 最好将它们分配到命令列表中。
- 尽可能按大小分配池。
- 确保重用分配器/命令列表,不要每帧重新创建。
Command Buffer是一个异步计算单元,可以描述GPU执行的过程(例如绘制调用),将数据从CPU-GPU可访问的内存复制到GPU的专用内存,并动态设置图形管道的各个方面,比如当前的scissor。Vulkan的Command Buffer为了达到重用和精确的控制,有着复杂的状态和转换(即有限状态机):
Command List是一组被批量推送到GPU的Command Buffer。这样做是为了让GPU一直处于繁忙状态,从而减少CPU和GPU之间的同步。每个Command List严格地按照顺序执行。Command List可以调用次级Command List(Bundle、Secondary Command List)。这两级的Command List都可以被调用多次,但需要等待上一次提交完成。
下图是DX12的命令相关的概念构成的层级结构关系图:
对于相似的Command List或Allocator,尽量复用之:
当重置Command List或Allocator时,尽量保持它们引用的资源不变(没有销毁或新的分配)。
但如果数据很不相似,则销毁之,销毁之前必须释放内存。
为了更好的性能,在Command方面的建议如下:
-
对Command Buffer使用双缓冲、三缓冲。在CPU上填充下一个,而前一个仍然在GPU上执行。
-
拆分一帧到多个Command Buffer。更有规律的GPU工作提交,命令越早提交越少延时。
-
限制Command Buffer数量。比如每帧15~30个。
-
将多个Command Buffer批处理到一个提交调用中,限制提交次数。比如每帧每个队列5个。
-
控制Command Buffer的粒度。提交大量的工作,避免多次小量的工作。
-
记录帧的一部分,每帧提交一次。
-
在多个线程上并行记录多个Command Buffer。
-
大多数对象和数据(包含但不限于Descriptor、CB等内存数据)在GPU上使用时不会被图形API执行引用计数或版本控制。确保它们在GPU使用时保持生命周期和不被修改。可以和Command Buffer的双缓冲、三缓冲一起使用。
-
使用Ring Buffer存储动态数据。
13.3.2 Render Pass
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Render Pass | FRHIRenderPassInfo | BeginRenderPass, EndRenderPass | - | VkRenderPass | MTLRenderPassDescriptor | - |
SubPass | FRHIRenderPassInfo | - | - | VkSubpassDescription | Programmable Blending | PLS |
绘制命令必须记录在Render Pass实例中,每个Render Pass实例定义了一组输入、输出图像资源,以便在渲染期间使用。
DirectX 12录制命令队列示意图。其中命令包含了资源、光栅化等类型。
现代移动GPU已经普遍支持TBR架构,为了更好地利用此架构特性,让Render Pass期间的数据保持在Tile缓存区内,便诞生了Subpass技术。利用Subpass技术可以显著降低带宽,提升渲染效率。更多请阅读12.4.13 subpass和10.4.4.2 Subpass渲染。
Vulkan Render Pass内涉及的各类概念、资源及交互关系。
在OpenGL,采用Pixel Local Storage的技术来模拟Subpass。Metal则使用Programmable Blending(PB)来模拟Subpass机制(下图)。
上:传统的多Pass渲染延迟光照,多个GBuffer纹理会在GBuffer Pass和Lighting Pass期间来回传输于Tile Memeory和System Memory之间;下:利用Metal的PB技术,使得GBuffer数据在GBuffer Pass和Lighting Pass期间一直保持在Tile Memroy内。
Metal利用Render Pass的Store和Load标记精确地控制Framebuffer在Tile内,从而极大地降低读取和写入带宽。
创建和使用一个Render Pass的伪代码如下:
Start a render pass
// 以下代码会循环若干次
Bind all the resources
Descriptor set(s)
Vertex and Index buffers
Pipeline state
Modify dynamic state
Draw
End render pass
Vulkan的Render Pass使用建议:
- 即使是几个subpass组成一个小的Render Pass,也是好做法。
- Depth pre-pass, G-buffer render, lighting, post-process
- 依赖不是必定需要的。
- 多个阴影贴图通道产生多个输出。
- 把要做的任务重叠到Render Pass中。
- 优先使用load op clear而不是vkCmdClearAttachment。
- 优先使用渲染通道附件的最终布局,而不是明确的Barrier。
- 充分利用“don’t care”。
- 使用解析附件执行MSAA解析。
更多Render Pass相关的说明请阅读:12.4.13 subpass和10.4.4.2 Subpass渲染。
13.3.3 Texture, Shader
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Texture | FRHITexture | ID3D12Resource | ID3D11Texture2D | vk::Image & vk::ImageView | MTLTexture | GLuint |
Shader | FRHIShader | ID3DBlob | ID3D11VertexShader, ID3D11PixelShader | vk::ShaderModule | MTLLibrary | GLuint |
大多数现代图形api都有绑定数据结构,以便将Uniform Buffer和纹理连接到需要这些数据的图形管道。Metal的独特之处在于,可以在命令编码器中使用setVertexBuffer绑定Uniform,比Vulkan、DirectX 12和OpenGL更容易构建。
13.3.4 Shader Binding
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Shader Binding | FRHIUniformBuffer | ID3D12RootSignature | ID3D11DeviceContext::VSSetConstantBuffers(...) | vk::PipelineLayout & vk::DescriptorSet | [MTLRenderCommandEncoder setVertexBuffer: uniformBuffer] | GLint |
Pipeline State | FGraphicsPipelineStateInitializer | ID3D12PipelineState | Various State Calls | vk::Pipeline | MTLRenderPipelineState | Various State Calls |
Descriptor | - | D3D12_ROOT_DESCRIPTOR | - | VkDescriptorBufferInfo, VkDescriptorImageInfo | argument | - |
Descriptor Heap | - | ID3D12DescriptorHeap | - | VkDescriptorPoolCreateInfo | heap | - |
Descriptor Table | - | D3D12_ROOT_DESCRIPTOR_TABLE | - | VkDescriptorSetLayoutCreateInfo | argument buffer | - |
Root Parameter | - | D3D12_ROOT_PARAMETER | - | VkDescriptorSetLayoutBinding | argument in shader parameter list | - |
Root Signature | - | ID3D12RootSignature | - | VkPipelineLayoutCreateInfo | - | - |
Pipeline State(管线状态)是在执行光栅绘制调用、计算调度或射线跟踪调度时将要执行的内容的总体描述。DirectX 11和OpenGL没有专门的图形管道对象,而是在执行绘制调用之间使用调用来设置管道状态。
Root Signature(根签名)是定义着色器可以访问哪些类型的资源的对象,比如常量缓冲区、结构化缓冲区、采样器、纹理、结构化缓冲区等等(下图)。
具体地说,Root Signature可以设置3种类型的资源和数据:Descriptor Table、Descriptor、Constant Data。
DirectX 12根签名数据结构示意图。
这三种资源在CPU和GPU的消耗刚好相反,需权衡它们的使用:
Root Signature3种类型(Descriptor Table、Descriptor、Constant Data)在GPU内存获取消耗依次降低,但CPU消耗依次提升。
更具体地说,改变Table的指针消耗非常小(只是改变指针,没有同步开销),但改变Table的内容比较困难(处于使用中的Table内容无法被修改,没有自动重命名机制)。
因此,需要尽量控制Root Signature的大小,有效控制Shader可见范围,只在必要时才更新Root Signature数据。
Root Signature在DirectX 12上最大可达64 DWORD,可以包含数据(会占用很大存储空间)、Descriptor(2 DWORD)、指向Descriptor Table的指针(下图)。
Descriptor(描述符)是一小块数据,用来描述一个着色器资源(如缓冲区、缓冲区视图、图像视图、采样器或组合图像采样器)的参数,只是不透明数据(没有OS生命周期管理),是硬件代表的视图。
Descriptor的数据图例。
Descriptor被组织成Descriptor Table(描述符表),这些Descriptor Table在命令记录期间被绑定,以便在随后的绘图命令中使用。
每个Descriptor Table中内容的编排由Descriptor Table中的Layout(布局)决定,该布局决定哪些Descriptor可以存储在其中,管道可以使用的Descriptor Table或Root Parameter(根参数)的序列在Root Signature中指定。每个管道对象使用的Descriptor Table和Root Parameter有数量限制。
Descriptor Heap(描述符堆)是处理内存分配的对象,用于存储着色器引用的对象的描述。
Root Signature、Root Parameter、Descriptor Table、Descriptor Heap的关系。其中Root Signature存储着若干个Root Parameter实例,每个Root Parameter可以是Descriptor Table、UAV、SRV等对象,Root Parameter的内存内容存在了Descriptor Heap中。
DX12的根签名在GPU内部的交互示意图。其中Root Signature在所有Shader Stage中是共享的。
下面举个Vulkan Descriptor Set的使用示例。已知有以下3个Descriptor Set A、B、C:
通过以下C++代码绑定它们:
vkBeginCommandBuffer();
// ...
vkCmdBindPipeline(); // Binds shader
// 绑定Descriptor Set B和C, 其中C在序号0, B在序号2. A没有被绑定.
vkCmdBindDescriptorSets(firstSet = 0, pDescriptorSets = &descriptor_set_c);
vkCmdBindDescriptorSets(firstSet = 2, pDescriptorSets = &descriptor_set_b);
vkCmdDraw(); // or dispatch
// ...
vkEndCommandBuffer();
则经过上述代码绑定之后,Shader资源的绑定序号如下图所示:
对应的GLSL代码如下:
layout(set = 0, binding = 0) uniform sampler2D myTextureSampler;
layout(set = 0, binding = 2) uniform uniformBuffer0 {
float someData;
} ubo_0;
layout(set = 0, binding = 3) uniform uniformBuffer1 {
float moreData;
} ubo_1;
layout(set = 2, binding = 0) buffer storageBuffer {
float myResults;
} ssbo;
对于复杂的渲染场景,应用程序可以修改只有变化了的资源集,并且要保持资源绑定的更改越少越好。下面是渲染伪代码:
foreach (scene) {
vkCmdBindDescriptorSet(0, 3, {sceneResources,modelResources,drawResources});
foreach (model) {
vkCmdBindDescriptorSet(1, 2, {modelResources,drawResources});
foreach (draw) {
vkCmdBindDescriptorSet(2, 1, {drawResources});
vkDraw();
}
}
}
对应的shader伪代码:
layout(set=0,binding=0) uniform { ... } sceneData;
layout(set=1,binding=0) uniform { ... } modelData;
layout(set=2,binding=0) uniform { ... } drawData;
void main() { }
Vulkan绑定Descriptor流程图。
下图是另一个Vulkan的VkDescriptorSetLayoutBinding案例:
关于着色器绑定的使用,建议如下:
-
Root Signature最好存储在单个Descriptor Heap中,使用RingBuffer数据结构,使用静态的Sampler(最多2032个)。
-
不要超过Root Signature的尺寸。
- Root Signature内的CBV和常量应该最可能每个Draw Call都改变。
- 大部分在CB内的常量数据不应该是根常量。
-
只把小的、频繁使用的每次绘制都会改变的常量,直接放到Root Signature。
-
按照更新频率拆分Descriptor Table,最频繁更新的放在最前面(仅DirectX 12,Vulkan相反,Metal未知)。
-
Per-Draw,Per-Material,Per-Light,Per-Frame。
-
通过将最频繁改变的数据放置到根签名前面,来提供更新频率提示给驱动程序。
-
-
在启动时复制Root Signature到SGPR。
- 在编译器就确定好布局。
- 只需要为每个着色阶段拷贝。
- 如果占用太多SGPR,Root Signature会被拆分到Local Memory(下图),应避免这种情况!!
-
尽可能地使用静态表,可以提升性能。
-
保持RST(根签名表)尽可能地小。可以使用多个RST。
-
目标是每个Draw Call只改变一个Slot。
-
将资源可见性限制到最小的阶段集。
- 如果没必要,不要使用D3D12_SHADER_VISIBILITY_ALL。
- 尽量使用DENY_xxx_SHADER_ROOT_ACCESS。
-
要小心,RST没有边界检测。
-
在更改根签名之后,不要让资源绑定未定义。
-
AMD特有建议:
- 只有常量和CBV的逐Draw Call改变应该在RST内。
- 如果每次绘制改变超过一个CBV,那么最好将CBV放在Table中。
-
NV特有建议:
- 将所有常量和CBV放在RST中。
- RST中的常量和CBV确实会加速着色器。
- 根常量不需要创建CBV,意味着更少的CPU工作。
- 将所有常量和CBV放在RST中。
-
尽量缓存并重用DescriptorSet。
Fortnite缓存并复用DescriptorSet图例。
13.3.5 Heap, Buffer
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Heap | FRHIResource | ID3D12Resource, ID3D12Heap | - | Vk::MemoryHeap | MTLBuffer | - |
Buffer | FRHIIndexBuffer, FRHIVertexBuffer | ID3D12Resource | ID3D11Buffer | vk::Buffer & vk::BufferView | MTLBuffer | GLuint |
Heap(堆)是包含GPU内存的对象,可以用来上传资源(如顶点缓冲、纹理)到GPU的专用内存。
Buffer(缓冲区)主要用于上传顶点索引、顶点属性、常量缓冲区等数据到GPU。
13.3.6 Fence, Barrier, Semaphore
概念 | UE | DirectX 12 | DirectX 11 | Vulkan | Metal | OpenGL |
---|---|---|---|---|---|---|
Fence | FRHIGPUFence | ID3D12Fence | ID3D11Fence | vk::Fence | MTLFence | glFenceSync |
Barrier | FRDGBarrierBatch | D3D12_RESOURCE_BARRIER | - | vkCmdPipelineBarrier | MTLFence | glMemoryBarrier |
Semaphore | - | HANDLE | HANDLE | vk::Semaphore | dispatch_semaphore_t | Varies by OS |
Event | FEvent | - | - | Vk::Event | MTLEvent, MTLSharedEvent | Varies by OS |
Fence(栅栏)是用于同步CPU和GPU的对象。CPU或GPU都可以被指示在栅栏处等待,以便另一个可以赶上。可以用来管理资源分配和回收,使管理总体图形内存使用更容易。
Barrier(屏障)是更细粒度的同步形式,用在Command Buffer内。
Semaphore(信号量)是用于引入操作之间依赖关系的对象,例如在向设备队列提交命令缓冲区之前,在获取交换链中的下一个图像之前等待。Vulkan的独特之处在于,信号量是API的一部分,而DirectX和Metal将其委托给OS调用。
Event(事件)和Barrier类似,用来同步Command Buffer内的操作。对DirectX和OpenGL而言,需要依赖操作系统的API来实现Event。在UE内部,FEvent用来同步线程之间的信号。
Vulkan同步机制:semaphore(信号)用于同步Queue;Fence(栅栏)用于同步GPU和CPU;Event(事件)和Barrier(屏障)用于同步Command Buffer。
Vulkan semaphore在多个Queue之间的同步案例。
13.4 管线机制
13.4.1 Resource Management
对于现代的硬件架构而言,常见的内存模型如下所示:
现代计算机内存模型架构图。从上往下,容量越来越小,但带宽越来越大。
对于DirectX 11等传统API而言,资源内存需要依赖操作系统来管理生命周期,内存填充遍布所有时间,大部分直接变成了显存,会导致溢出,回传到系统内存。这种情况在之前没有受到太多人关注,而且似乎我们都习惯了驱动程序在背后偷偷地做了很多额外的工作,即便它们并非我们想要的,并且可能会损耗性能。
DirectX 11内存管理模型图例。部分资源同时存在于Video和System Memory中。若Video Memory已经耗尽,部分资源不得不迁移到System Memory。
相反,DirectX 12、Vulkan、Metal等现代图形API允许应用程序精确地控制资源的存储位置、状态、转换、生命周期、依赖关系,以及指定精确的数据格式和布局、是否开启压缩等等。现代图形API的驱动程序也不会做过多额外的内存管理工作,所有权都归应用程序掌控,因为应用程序更加知道资源该如何管理。
DX11和DX12的内存分配对比图。DX11基于专用的内存块,而DX12基于堆分配。
现代图形API中,几乎所有任务都是延迟执行的,所以要确保不要更改仍在处理队列中的数据和资源。开发者需要处理资源的生命周期、存储管理和资源冲突。
利用现代图形API管理资源内存,首选要考虑的是预留内存空间。
// DirectX 12通过以下接口实现查询和预留显存
IDXGIAdapter3::QueryVideoMemoryInfo()
IDXGIAdapter3::SetVideoMemoryReservation()
如果是前台应用程序,QueryVideoMemory
会在空闲系统中启动大约一半的VRAM,如果更少,可能意味着另一个重量级应用已经在运行。
内存耗尽是一个最小规格问题(min spec issue),应用程序需要估量所需的内存空间,提供配置以修改预留内存的尺寸,并且需要根据硬件规格提供合理的选择值。
预留空间之后,DirectX 12可以通过MakeResident
二次分配内存。需要注意的是,MakeResident
是个同步操作,会卡住调用线程,直到内存分配完毕。它的使用建议如下:
-
对多次
MakeResident
进行合批。 -
必须从渲染线程抽离,放到额外的专用线程中。分页操作将与渲染相交织。(下图)
-
确保在使用前就准备好资源,否则即便已经使用了专用的资源线程,依然会引发卡顿。
对此,可以使用提前执行策略(Run-ahead Strategie)。提前预测现在和之后可能会用到什么资源,在渲染线程之前运行几帧,更多缓冲区将获得更少的卡顿,但会引入延迟。
也可以不使用residency机制,而是预加载可能用于系统内存的资源,不要立即移动它们到显存。当资源被使用时,才复制到Video Memory,然后重写描述符或重新映射页面(下图)。当需要减少内存使用时,反向操作并收回显存副本。
但是,这个方法对VR应用面临巨大挑战,会引发长时间延时的解决方案显然行不通。可以明智地使用系统内存,并在流(streaming)中具备良好的前瞻性。
另外,需要谨慎处理资源的冲突,需要用同步对象控制可能的资源冲突:
上:CPU在处理数据更新时和GPU处理绘制起了资源冲突;下:CPU需要显示加入同步等待,以便等待GPU处理完绘制调用之后,再执行数据更新。
常见的资源冲突情况:
- 阴影图。
- 延迟着色、光照。
- 实时反射和折射。
- ...
- 任何应用渲染目标作为后续渲染中贴图的情况。
13.4.1.1 Resource Allocation
在 Direct3D 11 中,当使用D3D11_MAP_WRITE_DISCARD标识调用ID3D11DeviceContext::Map
时,如果GPU仍然使用的缓冲区,runtime返回一个新内存区块的指针代替旧的缓冲数据。这让GPU能够在应用程序往新缓冲填充数据的同时仍然可以使用旧的数据,应用程序不需要额外的内存管理,旧的缓冲在GPU使用完后会自动销毁或重用。
D3D11等传统API在分配资源时,通常每块资源对应一个GPU VA(虚拟地址)和物理页面。
D3D11内存分配模型。
在 Direct3D 12 中,所有的动态更新(包括 constant buffer,dynamic vertex buffer,dynamic textures 等等)都由应用程序来控制。这些动态更新包括必要的 GPU fence 或 buffering,由应用程序来保证内存的可用性。
现代图形API需要应用程序控制资源的所有操作。
Vulkan创建资源步骤:先创建CPU可见的暂存缓冲区(staging buffer),再将数据从暂存缓冲区拷贝到显存中。
在D3D12等现代图形API中,资源的GPU VA和物理页面被分离开来,应用程序可以更好地分摊物理页面分配的开销,可以重用临时空置的内存,也可以调整场景不再使用的内存的用途。
D3D12内存分配模型。
不同的堆类型和分配的位置如下:
Heap Type | Memory Location |
---|---|
Default | Video Memory |
Upload | System Memory |
Readback | System Memory |
下表是可能的拷贝操作的组合:
Source | Destination |
---|---|
Upload | Default |
Default | Default |
Default | Readback |
Upload | Readback |
不同的组合在不同类型的Queue的拷贝速度存在很大的差异:
在RTX 2080上在堆类型之间复制64-256 MB数据时,命令队列之间的比较。
在RTX 2080上在堆类型之间复制数据时,跨所有命令队列的平均复制时间和数据大小之间的比较。
堆的类型和标记存在若干种,它们的用途和意义都有所不同:
对于Resource Heap,相关属性的描述如下:
资源创建则有3种方式:
-
提交(Committed)。单块的资源,D3D11风格。
-
放置(Placed)。在已有堆中偏移。
-
预留(Reserved)。像Tiled资源一样映射到堆上。
这3种资源的选择描述如下:
Heap Type | Desc |
---|---|
Committed | 需要逐资源驻留;不需要重叠(Aliasing)。 |
Placed | 更快地创建和销毁;可以在堆中分组相似的驻留;需要和其它资源重叠;小块资源。 |
Tiled / Reserved | 需要灵活的内存管理;可以容忍ResourceMap在CPU和GPU的开销。 |
下表是资源类型和VA、物理页面的支持关系:
Heap Type | Physical Page | Virtual Address |
---|---|---|
Committed | Yes | Yes |
Heap | Yes | No |
Placed | No | Yes |
Tiled / Reserved | No | Yes |
每种不同的GPU VA和物理页面的组合标记适用于不同的场景。下图是3种方式的分配机制示意图:
Committed资源使用建议:
-
用于RTV, DSV, UAV。
-
分配适合资源所需的最小尺寸的堆。
-
应用程序必须对每个资源调用MakeResident/Evict。
-
应用程序受操作系统分页逻辑的支配。
- 在“MakeResident”上,操作系统决定资源的放置位置。
- 同步调用,会卡住,直到它返回为止。
资源的整块分配和子分配(Suballocation)对比图如下:
面对如此多的类型和属性,我们可以根据需求来选择不同的用法和组合:
- 如果是涉及频繁的GPU读和写(如RT、DS、UAV):
- 分配显存:D3D12_HEAP_TYPE_DEFAULT / VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT。
- 最先分配。
- 如果是频繁的GPU读取,极少或只有一次CPU写入:
- 分配显存:D3D12_HEAP_TYPE_DEFAULT / VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT。
- 在系统内存分配staging copy buffer:D3D12_HEAP_TYPE_UPLOAD / VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,将数据从staging copy buffer拷贝到显存。
- 放置在系统内存中作为备份(fallback)。
- 如果是频繁的CPU写入和GPU读取:
- 如果是Vulkan和AMD GPU,用DEVICE_LOCAL + HOST_VISIBLE内存,以便直接在CPU上写,在GPU上读。
- 否则,在系统内存和显存各自保留一份拷贝,然后进行传输。
- 如果是频繁的GPU写入和CPU读取:
- 使用缓存的系统内存:D3D12_HEAP_TYPE_READBACK / HOST_VISIBLE + HOST_CACHED。
更高效的Heap使用建议:
-
首选由upload heap填充的default heap。
-
从一个或多个提交的上传缓冲区(committed upload buffer)资源中构建一个环形缓冲区(ring buffer),并让每个缓冲区永久映射以供CPU访问。
-
在CPU侧,顺序地写入数据到每个buffer,按需对齐偏移。
-
指示GPU在每帧结束时发出增加的Fence值的信号。
-
在GPU没有达到Fence只之前,不要修改upload heap的数据。
-
-
在整个渲染过程种,重用上传堆用来存放发送到GPU的动态数据。
-
创建更大的堆。
- 大约10-100 MB。
- 子分配(Sub-allocate)用以存放placed resource。
-
逐Heap调用MakeResident/Evict,而不是逐资源。
-
需要应用程序跟踪分配。同样,应用程序需要跟踪每个堆中空闲/使用的内存范