首页 > 其他分享 >从零开始的D3D12渲染框架 第0篇 设计思路

从零开始的D3D12渲染框架 第0篇 设计思路

时间:2023-10-07 11:23:04浏览次数:37  
标签:12 渲染 描述符 从零开始 DirectX GPU D3D12 CPU 资源

DirectX 12、Vulkan等下一代的渲染API在设计上相比OpenGL等上一代API有了很大的不同。下一代渲染API暴露了更多的GPU相关的细节部分,这允许程序员对GPU进行更加细致的控制,但同时也使得API本身变得更加琐碎与难用。这一系列文章用来记录我封装DirectX 12的思路与心得,篇章之间不会有很强的关联性。

在动笔之前,我的框架已经处于可用的状态了,至少你可以很容易地画一个彩色的三角形。你可以在GitHub找到它的源代码:ink。我想,对照着源代码会更容易理解文章的内容。

本文是这一系列的第0篇,我打算在这里讲讲设计思路,因此本文不会沉溺于细节中,也不会变得很难,这意味着这些内容应当同样适用于Vulkan与Metal。不过,这一系列的文章并不是面向小白的教程。如果你没有接触过渲染,我建议你至少先去学习一下计算机图形学的基础知识,并且试一试OpenGL

在文章的开头我们提到了,下一代渲染API暴露了更多与硬件相关的细节部分。因此,本文首先会简要介绍CPU与GPU是如何协同工作的,随后讲一讲DirectX 12的封装思路。

绘制一个三角形

要如何在屏幕上绘制一个三角形呢?我们很容易想到渲染管线:

  1. 首先,在输入装配阶段,需要装配顶点数据;
  2. 然后,在顶点着色器阶段,根据我们编写的vertex shader对每个顶点进行处理;
  3. 在光栅化阶段,对模型执行光栅化;
  4. 在像素着色器阶段,根据我们编写的pixel shader(fragment shader)对每个像素(片元)进行处理;
  5. 在输出合并阶段,将最终的颜色输出到屏幕上。

这是一个普通的Hello Trangle的渲染管线的流程,不过这不是本文的重点。根据每个阶段GPU要做的工作,如果我们深入思考一下,不难想到这么一些问题:

  1. 输入装配阶段需要访问顶点的数据,这些顶点的数据存在于哪里呢?
  2. GPU是不能访问内存的,那么如何让GPU访问到内存中的数据呢?(请不要深究UMA架构之类的问题)
  3. GPU最终要将颜色输出到屏幕上,那么如果渲染只完成了一半,为什么屏幕画面不会撕裂?
  4. ……

关于第一个问题,我们很容易想到顶点数据应当被放在显存里,GPU是能够直接访问显存的。由此我们可以引出第一个话题——

资源管理

在使用OpenGL的时候,我们可以很容易地glGenBuffers,然后很容易地使用glBufferData上传数据,而不用关心细节。但在使用DirectX 12的时候,我们需要熟悉各种资源才能正确地使用它们。

从硬件的角度来讲,DirectX 12中需要管理的资源可以分为GPU资源(各种resource)描述符(Descriptor)。由于各种视图(view)都是描述符,因此在这一系列文章中,我会混用descriptor与view。

GPU资源

当创建DirectX 12的资源时,ID3D12Device::CreateCommittedResource要求我们传入两个大结构体,一个是D3D12_HEAP_PROPERTIES,另一个是D3D12_RESOURCE_DESC。前者用来描述资源的存储空间信息,后者用来描述资源的类型等元数据。DirectX 12中所有的GPU资源都可以从这两个方面考虑。

存储空间

我们很容易想到存储空间分为内存与显存两类,实际上通常还存在一个容量不大的高速缓存区域,CPU与GPU都可以高速地访问这块缓存。

DirectX 12将存储空间划分成了三类:默认堆(Default Heap)上传堆(Upload Heap)回读堆(Readback Heap)

默认堆里的数据被当做会频繁地被GPU使用,通常我们可以认为默认堆等价于显存。而在使用DirectX 12的时候,我们也不能直接读写默认堆中的数据,必须借助上传堆与回读堆间接地访问默认堆。

上传堆正如其名,是用来上传数据的。CPU端可以向其中写入数据,但不能读取数据;GPU端可以从其中读取数据,但不能写入数据。驱动程序在实现时,上传堆可能会占用高速缓存区域,也可能使用内存与显存同步的方法等。需要注意的是,此处提出的只是可能的实现方案,DirectX并不规定驱动程序具体如何实现,回读堆同理。

回读堆恰好与上传堆相反。CPU端可以从其中读取数据,但不能写入数据;而GPU端可以向其中写入数据,但不能读取数据。驱动程序在实现时,可以将显存的一部分直接映射到内存供其访问,也可能是将显存中的数据拷贝直接到内存再使用。

DirectX 12在渲染时用到的各种资源,不论是各种buffer还是纹理等,都必须存放于这三种存储空间之一才能被GPU使用。

在这里顺便提一下,DirectX 12与Vulkan是允许乃至推荐手动管理这些存储资源的,所以你会发现这三类存储都被称为堆(Heap)。不过管理存储资源是一个十分复杂的话题,而我并不是这方面的专家,因此不会讨论手动管理存储相关的内容。

资源类型

存储资源并不能告诉GPU如何使用这些资源,我们还需要提供一些元数据,比如纹理的宽与高。我们不妨首先考虑一下常用的一些资源类型:

  • 缓冲区(Buffer),比如顶点缓冲区、索引缓冲区等;
  • 2D纹理,最常用的纹理资源,可以用作纹理贴图,也可以用作法线贴图、G-Buffer等;
  • 深度缓冲,用于进行深度测试与剔除;
  • 其他一些资源类型,比如各种纹理数组等。

DirectX 12对于资源的区分实际上没有这么细致,在创建资源时只需要确定资源是缓冲区、纹理还是纹理数组以及像素格式(pixel format)就足够了。资源究竟应当怎样被使用是由描述符来确定的,我会在讨论描述符时解释这些内容。

资源的类型与存储空间类型实际上是互不相干的。比如,我们既可以把缓冲区放在上传堆上,也可以放在默认堆上。它们主要的区别在于程序性能。

描述符

描述符是什么

其实称之为视图对于C++开发者要更亲切一些,因为描述符与std::string_view的作用非常类似。std::string_view的作用是指向一个字符串片段。比如,它可以只指向std::string的某一部分而不是全部,描述符也是如此。不同之处在于,GPU资源要比字符串复杂得多,描述符除了引用GPU资源的一部分,还需要描述这部分资源会怎样被使用。比如,是只读还是可读写、应当以怎样的像素格式(pixel format)解释这段资源等等。

DirectX对于资源的这种设计很容易让初次接触的人摸不着头脑——既然我们都有了资源了,那么直接使用资源就好了,为什么还要拐弯抹角地弄出个描述符?

首先,还是类比std::string_view——恰当地使用std::string_view要比全部使用std::string性能更好。一方面避免了拷贝与内存分配,另一方面也避免了相同的字符串反复占用内存。

从另一方面来说,我们经常需要以不同的方式来解释资源。比如,也许在上一个render pass,这个资源是深度缓冲区,但在下一个render pass,我希望它能作为纹理被采样。在这种情况下,反复创建与拷贝同一个资源是不现实的。

描述符的管理

在DirectX 12中有Constant Buffer View、Shader Resource View、Unordered Access View、Sampler View、Render Target View和Depth Stencil View六种描述符,它们各自代表一类资源的使用方式。从硬件角度上来讲,又分成Shader-Visible与Non-Shader-Visible两类。关于描述符管理的具体内容,我计划另起一篇文章专门讲一讲。

DirectX 12中的描述符完全需要手动管理,这个管理主要表现在描述符堆(Descriptor Heap)上。描述符堆相当于一段连续的内存,我们需要决定哪一段内存(哪一些描述符)分配给谁,并且自己处理资源的回收。并且,要将资源绑定到根描述符表(Root Descriptor Table)上,我们会需要一段连续的描述符,这使描述符的管理变得非常复杂。如果对于操作系统的内存管理比较熟悉的话,你也许会联想到伙伴算法和slab算法,不过在描述符堆上实现这两种算法过于复杂。DynamicDescriptorHeap就是用来专门管理描述符表所需的描述符的,同样会在以后的文章中详细讲解。

让CPU与GPU协同工作

CPU与GPU是计算机中两个不同的部件,它们使用不同的时钟,互不干扰地并行工作。这意味着——它们是异步的!既然它们异步地工作,那么自然就需要一些方法来同步二者之间的工作。不过在研究它们如何“协同”之前,我们需要先搞明白它们之间如何“工作”。

命令列表

让我们看一下OpenGL的API是如何工作的。比如,CPU端调用了glDrawElements

+-----------------+                +-----------------------+
|       CPU       |                |          GPU          |
+-----------------+                +-----------------------+
| glDrawElements  | -------------> |   Received Command    |
+-----------------+                +-----------------------+
|                 |                |                       |
|  Wait and Idle  |                |    Execute Command    |
|                 |                |                       |
+-----------------+                +-----------------------+
| Restore Program | <------------- | Report Task Completed |
+-----------------+                +-----------------------+

当CPU端调用一条命令时,驱动程序将这条命令发送给GPU,同时阻塞CPU端,直到GPU通知该命令已经完成。这个API做了两件事——将命令发送给GPU、等待GPU完成。这当然很不好——CPU等待GPU的时候明明空闲着,但什么也没法干。除此之外,CPU每调用一次API都需要经过这么一个流程,而“将命令发送给GPU端”这个操作本身是很费时的。

下一代API在设计上考虑到了这个问题,命令的记录与提交、CPU与GPU的同步被区分开来。CPU要控制GPU仍然需要通过发送命令来完成,但不再需要一条命令向GPU端提交一次。以DirectX 12为例,我们首先使用ID3D12GraphicsCommandList在CPU这边记录下我们想让GPU做的工作,然后通过ID3D12CommandQueue将记录的命令一次性全部提交给GPU。命令提交给GPU后立刻返回,不会等待GPU执行完成。这使CPU与GPU的协同工作变成了真正意义上的异步工作。

任务同步

既然任务模型相比以往有所不同,那么同步方法也会有所变化。基于CommandBufferCommandList)的任务提交方式很难实现逐命令粒度的同步,不过好在我们在实际使用中几乎没有这种需求。DirectX 12与Vulkan的同步方法都是以CommandBufferCommandList)为粒度的,如下图所示:

+------------------------+
| Submit CommandBuffer 0 |
+------------------------+
|     Signal Fence 0     |
+------------------------+
| Submit CommandBuffer 1 |
+------------------------+
|     Signal Fence 1     |
+------------------------+
|    Wait for Fence 0    |
+------------------------+

Signal Fence的意思是在提交的CommandBuffer以后做一个标记。比如,Signal Fence 0表示CommandBuffer 0完成执行的时间节点,而Wait for Fence 0就表示阻塞CPU,等待CommandBuffer 0中的所有命令执行完成。那么自然,Wait for Fence 1(上表中没有这条命令)就是等待CommandBuffer 0CommandBuffer 1都完成了。

这里需要注意一点,Vulkan与DirectX 12在这里稍有不同。DirectX 12要求先提交到CommandQueueCommandList必须先完成,因此自然满足上述同步模型。而Vulkan需要手动使用VkSemaphore控制CommandBuffer之间的依赖顺序。

设计思路

Windows生IDXGIFactoryIDXGIFactoryID3D12DeviceID3D12Device生万物——我自己说的。

IDXGISwapChainIDXGIFactory生的,你这话不对——还是我自己说的。

我们来整理一下上文提到的各种内容,并且将其分类如下:

  • 存储资源,包括ID3D12HeapID3D12Resource等,是各种缓冲区、纹理的实际载体。
  • 描述符资源,包括各种描述符。
  • 管线资源,包括渲染管线、计算管线以及根签名。其中,根签名与描述符资源是强相关的。上文没有提到管线资源,这是因为管线资源本身的管理比较简单。而且DirectX 12中的渲染管线与教科书中讲的完全一致——可编程阶段需要提供着色器代码,可配置阶段需要填写配置项,固定阶段则无需关心。
  • 命令的提交与同步,包括ID3D12CommandListID3D12CommandQueueID3D12Fence
  • 对GPU的抽象,ID3D12Device

我们来理一理这些内容之间的关系:

  1. 存储资源
  • 存储资源是资源的实际载体,而描述符是对存储资源的引用,因此实际上的描述符应当由存储资源来派生;
  • 有时候需要在GPU端操作存储资源本身,因此命令列表需要访问存储资源;
  1. 描述符资源
  • 描述符资源是存储资源的引用;
  • 根签名决定了描述符资源的排布,但根签名不需要直接访问描述符资源,根签名与描述符资源的耦合发生在命令列表处;
  • 命令列表需要向根签名中填入实际的描述符。其中,描述符表需要一段连续的描述符,这一部分由命令列表来管理;
  1. 管线资源
  • 命令列表需要设置管线才能执行渲染与计算任务;
  • 根签名决定了描述符的排布;
  1. 命令与同步资源
  • 应用程序只有通过命令列表才能与GPU交互,因此命令列表需要访问上述所有资源;
  • 提交了命令列表才需要同步,同步的节点由命令列表确定,二者具有强相关性;
  • 命令列表需要管理一些临时资源,比如临时的上传缓冲、DynamicDescriptorHeap用于描述符表等,这些内容与其他资源无关;

根据此,也就不难理解ink的设计思路。首先,我们需要一个RenderDevice,它是一切的基础。同时,我将所有需要全局处理的状态与资源管理都塞到RenderDevice了,所以你会发现这个类很大,而且有些代码写得很脏。

存储资源我首先分成了两类,一类是GpuBuffer,另一类是PixelBuffer。前者作为各种缓冲区的抽象,后者作为各种纹理(广义上的)的抽象。PixelBuffer又分成了ColorBufferDepthBufferTexture2DColorBuffer可以用作render target,DepthBuffer可以用作深度缓冲,Texture2D则是各种2D纹理与纹理数组。

针对不同类型的存储资源,它们各自带着不同的Non-Shader-Visible描述符。比如,GpuBuffer必然支持unordered access,因此有一个用于逐字节访问的UAV;ColorBuffer可以用作render target,因此带有RTV等等。这些资源自带的描述符都引用这些资源的全部内容(前文提到过,描述符可以部分引用资源)。

对于渲染与计算管线只有浅浅的一层封装,从源代码可以很容易看出是如何设计的,这里不再赘述。

CommandBuffer进行了非常复杂的封装,因为要在这里管理各种临时资源,以及GPU命令的提交与同步。不过CommandBuffer本身与其他模块相对独立,封装难度并不大。但是,与命令和同步相关的另一部分存在于RenderDevice,即CommandQueueFence。我只给每个RenderDevice创建了一个Direct类型的ID3D12CommandQueue,一是因为Direct类型能够覆盖所有其他类型的命令,二是多个CommandQueue之间的命令同步非常复杂。关于ID3D12Fence,我同样在RenderDevice中维护了与唯一一个CommandQueue关联的Fence,以及它的下一个Fence Value。

inkrender文件夹下有5个头文件,基本上恰好与这几个部分相对应。

后记

你会发现我在渲染部分的代码里用到了大量的友元,一方面我认为渲染部分本身就是高内聚的,这么写影响并不大。另一方面,不暴露内部方法能使得这些接口更难以被用错。实际上我在代码中用到友元时,大部分时候只是想把某个特定的方法暴露给另一个类而已。

关于错误处理,我使用了异常。实际上,当渲染API返回错误时,大部分时候都是不可恢复的,而人们往往也是使用断言或者打日志来处理,然后让程序崩溃。异常一方面是用起来很方便,另一方面,我仍然希望给调用者选择处理错误的权利,尽管我也建议当遇到错误时,应当让程序迅速崩溃。

标签:12,渲染,描述符,从零开始,DirectX,GPU,D3D12,CPU,资源
From: https://www.cnblogs.com/icysky/p/17745854.html

相关文章

  • 2023-10-06 useState数据渲染不同步==》async await
    业务:点击按钮增加数据并渲染出来。框架:antd+ts+react。原来写法:const[tagData,setTagData]=useState<Array<number>>([]);点击事件://添加标签constaddTag=()=>{letarr:(number)[]=[];arr=tagData;arr.push(Math.floor(Math.random()......
  • kali安装到手后必须要做的几件事——kali从零开始配置
    记录一下配置kali的过程,方便下次需要直接复制粘贴直接终端按照顺序输入就可以配置好kali更换国内源sudosu进入root模式vim/etc/apt/sources.list编辑软件源配置文件i进入编辑模式,esc退出,:wq保存退出#官方源#debhttp://http.kali.org/kalikali-rollingmain......
  • 「Artlantis下载」Artlantis渲染器2020版 安装包下载方式
    Artlantis2020官方版是一款三维渲染软件,Artlantis2020官方版主要用于建筑绘图场景的三维渲染,对于建筑设计师来说,这款软件可以帮助极大提高渲染效率。软件可与市场上的所有3D建模软件兼容,是创建逼真的渲染和动画的最简单,最快的解决方案。自带渲染引擎,用户无需借助其他软件,专家和初......
  • 从零开始学Unity(一)-主要窗口及功能区域
    声明:本人学习过程跟随NeilianAryan大佬课程从零开始的Unity魔法学堂学习部分思路及案例可能来源于课程内案例做此笔记仅为记录学习过程方面日后整理及回顾如有侵权联系删除谢谢!!unity主要窗口Scene、Game、Hierarchy、Inspector、Project、ConsoleScene场景窗口在默......
  • 模板渲染的使用
    现在一般都是前后端分离开发了,模板相对较少使用。和django一样,flask也是支持模板渲染的。flask中默认使用的是jinjia2模板渲染语言。#template_folder:指定模板文件查找的目录(默认就是templates)app=Flask(__name__,template_folder="templates")使用模板渲染返回,前面r......
  • 什么是 Angular 应用服务器端的预渲染技术 - prerendering
    Angular服务器端预渲染(ServerPrerendering):构建更快速、更友好的Web应用Angular是一种强大的前端框架,用于构建现代Web应用程序。然而,随着应用规模的增长,性能问题也可能随之而来。为了提高Angular应用的性能和用户体验,开发人员可以采用各种技术和方法。其中之一就是服务器端预渲......
  • [官方培训]10-UE实时渲染后期 李文磊 Epic 笔记
    实时渲染后期什么是后期需求:快速地基于镜头对最终画面内容及形式(节奏,色调,气氛)的控制传统后期:像素UE后期:像素和对象UE后期UE后期对象:对象和像素(Buffer)对象调节:光影,材质,特效,雾效,Sequencer像素Buffer处理:AA,相机及镜头效果,ColorGrading,Tonemapping,PPM,Decal,Translucency,Compo......
  • 【13.0】Fastapi中的Jinja2模板渲染前端页面
    【一】创建Jinja2引擎#必须模块fromfastapiimportRequest#必须模块fromfastapi.templatingimportJinja2Templates#创建子路由application=APIRouter()#创建前端页面配置templates=Jinja2Templates(directory='./coronavirus/templates')#初始化数据库......
  • dwm.exe是Windows操作系统中的一个进程,它是Desktop Window Manager的缩写,负责管理和渲
    dwm.exe是Windows操作系统中的一个进程,它是DesktopWindowManager的缩写,负责管理和渲染桌面以及窗口的显示效果。DesktopWindowManager(DWM)是WindowsVista及其之后版本引入的一个特性,它通过使用硬件加速来实现窗口的合成和渲染,提供了透明、窗口阴影、动画效果等视觉特效。它还......
  • 理解React页面渲染原理,如何优化React性能?
    ReactJSX转换成真实DOM过程当使用React编写应用程序时,可以使用JSX语法来描述用户界面的结构。JSX是一种类似于HTML的语法,但实际上它是一种JavaScript的扩展,用于定义React元素。React元素描述了我们想要在界面上看到的内容和结构。在运行React应用程序时,JSX会被转换成真实的DOM元素......