Design Philosophy
dx12由于开放了更底层的硬件接口,使开发者可以完全控制计算任务什么时候提交到gpu,因而在使用上比起之前的接口有两改进:
- 没有单一的immidiate context,因此可以使用多线程handle多个context。
- 设置好的一次渲染过程可以重复使用。
dx12的设计,使cpu/gpu之间更像client/server之间的关系。cpu在本地分配堆存储数据,通过api允许的接口申请gpu管辖的内存,通过公用数据结构的形式上传cpu保持的数据和代码到gpu,再由gpu计算渲染结果返回公用内存,最后通过一套输出接口(dxgi)输出到显示器。同时cpu也可以访问计算后的结果。
dx12的内存模型,也更加接近于由开发者申请提交,cpu填充,开发者调度使用,一改以往完全由后台调度的策略。开发者需要为正确的使用和调用内存对象做更多工作,同时也能因此获得更高的app执行效率。这之间的关系就好比是c# gc和cpp alloc。
Components
Command Queue and Command List
为了实现能够多线程提交和复用渲染,需要从结构上更改d3d app的gpu渲染工作流。dx12针对之前的结构,做出了三个重大改进:
- 去除immediate context。使用command list代替原本的immediate context。每个command list包含原本api中的图源和渲染状态,可以在单独线程中存在。
- app现在可以控制gpu如何组织调用渲染过程。使得相同的渲染过程可以重复调用。
- app现在允许控制什么时候如何提交渲染过程。
Command list
command list允许app保存一个绘制或者状态改变的调用过程,并在晚些时候通过gpu来执行这个过程。
- 为了充分利用现代硬件的功能,dx12加入了second level of command list。被称作bundles。
- first level command list(direct command list)可以被直接引用,bundles则允许app在direct command list中打包一些小型api commands。
- bundles在创建时会被尽可能的预处理以提高之后的执行效率。
- bundles可以被包含在多个direct command list中,并被同个的command list多次执行。
- 使用bundles可以提高cpu单线程效率。由于bundles是被预处理并可以多次提交的,它对其中捆绑的操作有一定要求。
- 如果一个command list已经确认执行完毕。在提交新的执行请求前,它可以被重复多次执行。
- 在gpu上执行工作,app需要有效的通过command queue提交command list。
- 一个direct command list可以被直接提交并多次执行,但是在下次提交执行之前,app不知道前次的command list是否执行完毕。
- bundles没有并发限制可以被执行多次。但bundles不能通过command queue直接提交。
- 任何线程都可以在任何时间向任意的command queue提交command list,运行时会按照预先排列好的提交顺序,自动序列化command queue中的command list。
Command Queue
command queue用来提交并执行command list。这种架构方式允许开发者更高效的使用cpu和gpu。
- command queue的使用可以让开发者避免由于意外的同步造成的效率损失。
- command queue的使用可以让开发者在更高级别的同步发生时,使用更加高效(多个线程同时提交)精确(使用下标专门提交某个command list)的方式。这意味着运行时和图形驱动将在工程化的并发中花费更少的时间。
- command queue的使用可以使大消耗的操作更有效率。
command queue的结构使接口达成了这些改进:
- 增加并发:app可以在进行前台工作(如渲染)时,同时进行更深层次的后台工作(如解码视频编码)。
- 同步执行低优先级的gpu工作:command queue结构允许gpu在一个非同步线程中无锁的执行低优先级gpu工作和原子操作。
- 高优先级工作:command queue的设计允许脚本打断3d渲染工作,去做少量高优先级计算,以便cpu能够尽早获得结果。
- 每个command queue只能够保存并提交一个类型的command list。
Descriptor Heaps (DH)
cpu修改创建,用来保存和提交descriptor。
- 可以创建完整的heap来保存提交新的descriptor。也可以重新分配空间来修改原有的提交顺序。
- 在被command list引用之前,可以直接由cpu编辑修改。但当引用DH的command list被提交后,该DH则不能被修改。
- dx12必须通过DH来访问descriptor。
- DH可以通过以下策略进行管理:
- 为下次draw call填充一个fresh area。在每次command list访问时,移动DT pointer到fresh开头。优点:可以避免记录特定DT pointer的位置。缺点:DH上很可能有许多重复的descriptor,当渲染相同或近似场景时,DH的内存将很快被用完。对于那些在cpu上记录,在gpu上渲染的DH,这种策略需要避免产生访问冲突。(动态填充策略)
- 预先根据descriptor的需要创建DH作为一个场景的一部分,之后在绘制时仅仅设置DT即可。
- 将DH当作一个包含所有需要的descriptor和其所在位置的大数组。draw call通过index去访问固定区段的heap内容。(预先填充策略)
- 确保root constants and root descriptors通过read/write来访问,而非完整的重新创建,可以在大多数硬件上进一步提升DH性能。
Descriptor
用gpu非公开的数据格式描述一个gpu对象的小区块。包含以下四类(PS:其中1,2,3可以在同个heap中存在,4则需要单独heap保存。):
- Shader Resource Views (SRVs)
- Unordered Access Views (UAVs)
- Constant Buffer Views (CBVs)
- Samplers
硬件驱动不会保有descriptor。驱动仅仅绑定render target以保证swap chain工作正常。由于硬件不会保有descriptor对象,每个对象需要保有驱动所需要的资源地址以便硬件访问。
Descriptor Table(DT)
用来引用DH上一段descriptor的结构。使硬件可以使用更加快捷轻量的方式访问drescriptor。DT以在图形管线生成一个root signature并使用索引的方式来引用DT保有的资源。
Graphic Pipeline State
The Direct3d 11 Pipelines
在说明d3d12 pipeline之前,首先描述一下dx11的render pipeline。本质上,dx12的pipeline是针对该版本的扩展。
d3d11 pipeline大体分为以下阶段:
- Input Assmebler
- 使用primitive type组织primitive data,提供其他阶段使用。
- 链接系统默认值以便帮助shader更加有效的执行。
- Vertex Shader
- 处理从IA阶段传入的顶点数据。
- 每个顶点产生一次数据,计算并产生一次输出传给下个阶段。(理论上有毒少个顶点就会有多少次Vertex Shader调用
- VS一次最大允许16个32位浮点数的传入和传出。
- VS必须存在并且必须传出一个值。
- Tessellation
- 实际用来细分曲面,在早期管线中,这部分由cpu实现。现在将它移动到gpu。
- Tessellation做的事,是顶点和面数较小的图元上,通过计算增加顶点和面数,并传给下个阶段。
- Tessellation阶段的意义在于,可以减少数据的输入和上载数量,但必然的会消耗gpu运算效率。
- Tessellation阶段一般由硬件实现,它有两个相关的可编程阶段
- Hull shader
- 每个三角面片调用一次。把输入的低面数的控制点,扩展成更高的面数的控制点。比如3个控制点的三角形扩展成9个控制点的三个三角形。从而构成更多的表面细节。
- 单次最多输入1-32个控制点。最多输出1-32个控制点,输出的控制点数跟镶嵌参数(tessellation factors)无关。从Hull输出的控制点和面片常量(path constant data)可以被Domain消耗。输出的Tessellation Factors会被Tesselator使用。
- Tessellation factors决定面片被细分的程度。
- Domain shader
- Tesselator每输出一个纹理坐标运行一次。
- 使用Hull输出的图元控制点。
- 输出顶点位置。
- Hull shader
- Geometry Shader
- 输入图元的顶点和邻接顶点,对几何元素进行计算,以stream的形式输出图元顶点。
- 通过IA阶段系统默认值SV_PrimitiveID确定顶点属于哪个图元。
- 为每个primitive_id生成顶点,顶点可以任意设定,并通过RestarStrip函数结束一个拓扑结构的生成,并开启下一个。假使调用RestartStrip时,提供的顶点无法形成完整的拓扑结构(比如三角面只给了两个顶点),id对应的图元将被丢弃。
- GS通过将顶点Append到Stream中,一次输出一个顶点。可以选择的预设流对象有:PointStream,LineStream和TriangleStream。每个流对象都是一个模板对象,决定了GS输出的拓扑结构。增加到流对象中的顶点的类型,则由顶点的模板决定。
- GS由其他阶段并发调用。但输出的流却是线性流。每个GS的输出彼此独立,但在一个完整buffer或heap中有其固定顺序。
- 当sampler不有需要屏幕空间导数时(samplerstate contains samplelevel,samplecmplevelzero,samplegrad),几何着色器可以执行加载和纹理采样操作。
- 常在GS中实现的功能有:
- 硬件加速粒子
- 阴影体
- 邻接拓扑结构计算(比如在GS为3D物体中描边)
- Stream-Output
- 从VS或GS中,以流的形式输出一段数据,主要是已经组成图元拓扑结构的顶点数据:
- 在VS中会自动按图元拓扑结构生成顶点数据。
- 在GS中没能组成图元拓扑结构的数据将被丢弃。
- 包含有邻接数据的顶点,在组成图源输入流之前,邻接信息会被丢弃。
- 顶点数据可以直接被Rasterizer读取,也可以拷贝到内存buffer中供cpu使用。
- 拷贝到内存buffer中的数据,被修改后,还一个反馈回pipeline,有以下两种方式:
- 通过IA-stage,在下次提交时返回。
- buffer为constant buffer/constant object,使用HLSL中的BufferObject.Load函数直接读取。
- SO阶段同时支持最大4个缓冲区
- 从VS或GS中,以流的形式输出一段数据,主要是已经组成图元拓扑结构的顶点数据:
- Rasterizer
- 用光栅化算法(一般是凸三角形的基线扫描算法)将图元数据转化为像素颜色数据。关于光栅化算法的详细讲解在这里。
- 设置pixel shader为null时,被光栅化的像素,将不会调用ps阶段来处理该像素的颜色。
- Pixel Shader
- 由rasterizer阶段唤醒,每转化一个像素调用一次,当设置为null时,不调用。
- 当对一个纹理进行多重采样时,对每次采样转化的像素进行深度或者模板测试时,pixel shader被调用一次,同过测试的样品将使用ps输出的颜色作为显示颜色。
- ps的输入一般为从gs阶段传递来的顶点属性。
- ps可以输出4分量的像素颜色,并且允许通过SV_Depth在oDepth寄存器中替换深度测试的结果并输出。ps不能对模板缓冲进行更改。
Output Merge
组合ps输出,depth/stencil test输出,和各pipline阶段输出数据,将它们组合成最终像素并输出在render target上。OM决定了最后输出的最终颜色。
PS:在dx10之前的版本,OM最终还需要使用alpha-test state决定像素是否输出到render target上。dx10之后,则通过depth/stencil test在ps中决定。深度模板缓冲区(作为纹理资源创建)可以包含深度数据和模板数据。深度数据用于确定哪些像素距离摄像机最近,模板数据用于屏蔽哪些像素可以更新。最终,输出合并阶段使用深度和模板值数据来确定是否应绘制像素。下图表示深度或者模板测试的流程。关于如何配置使用深度和模板测试,参阅这里。

由于通过vertex.z来进行depth test。depth buffer有时也被称作z-buffer(就跟glow和bloom的关系一样,一辈子分不清谁是谁)。在OM中,深度缓冲区根据图像的格式或精度,把深度值约束在一个固定范围,一般z=min(Viewport.MaxDepth,max(Viewport.MinDepth,z)。约束后,深度值将跟现有的深度值进行比较。如果这里没有绑定深度缓冲,那么深度测试总是通过(既该深度的像素总是被显示)。
每个管线只允许激活一个depth/stecil-buffer。任何bound(关于bounds的解释在本文开头)的大小必须与depth/stecil view的大小相匹配(大小主要指长宽)。但view所引用的资源,不需要匹配。PS:这里大体意思是说,一个view引用的资源可能比view本身的size大或者小,depth/stecil test约束的只是view的size,与view引用的实际资源无关。既,一个800x600的target view只可以绑定800x600的depth buffer,但可以引用1920x1080的texture作为输入。

理论上OM阶段允许进行两次混合,一次混合RGB颜色,一次单独混合Alpha数据。这里描述了如何使用API配置来决定如何在OM阶段混合像素。
- 在dx10之前的版本,固定管线混合可以独立的应用于每一个render target上。在dx10之后,管线中仅存一个desciptor,因而一个混合模式,将被应用在所有render targets中。
The Direct3d 12 Pipelines
下图,是完整的d3d12工作流水线:

可以发现,右边从IA到OM状态,与dx11工作流几乎一致,这里也是为何需要提前描述dx11流水线的原因。在dx12中,管线改进核心,主要在IA之前数据准备阶段和Pipeline state上。