[引擎开发] 杂谈ue4中的Vulkan

        接触Vulkan大概也有大半年,概述一下自己这段时间了解到的东西。本文实际上是杂谈性质而非综述性质,带有严重的主观认知,因此并没有那么严谨。

        使用Vulkan会带来什么呢?简单来说就是对底层更好的控制。这意味着我们能够有更多的手段去提升绘制的效率。这里Vulkan主要能够提升的是CPU端的效率,GPU端的效率是无法直接提升的。

        这里所说的提升CPU的效率,实际上描述的是Vulkan能够更好地控制渲染数据的准备,那么这个渲染数据的准备具体来说就是完成渲染指令的编码。

        那么作为开发者来说,在已经封装好的Vulkan框架下,还有必要了解Vulkan的实现细节吗?在我看来,还是很有必要的。一个通用的引擎提供的是比较普适性的封装,这意味着它基本不会出错,但并不会考虑到每个项目的实际情况,进而没有办法发挥出Vulkan本身的优越性。

编码渲染指令

        Vulkan的工作简单来看就是编码渲染数据,提交给GPU,如此反复。编码渲染数据是在CPU段发生的事情,因此会消耗CPU时间。而直到数据被提交到GPU,GPU才会知道自己需要做什么。

        在Vulkan中,Command Buffer就是用于记录绘图操作、内存传输以及计算调度等任务的缓冲区。我们所谓的编码渲染数据,就是填充Command Buffer,所谓的提交到GPU,就是把Command Buffer从CPU传输到GPU,整个过程从另外一个角度来看就是一个数据流的过程。

        从这里可以看出来,我们之所以说Vulkan提供了更底层的控制,也就是说它其实把渲染的本质暴露了出来:填充编码了渲染任务的缓冲区,并把这个缓冲区数据传输到GPU。而在传统图形API中,Command Buffer的概念是隐藏的,我们只能指定要做什么,而我们指定的事情看起来就像是GPU即时去做的。

        从功能性上来看,Command Buffer包含如下三种类:

        ① 编码绘制指令

        ② 编码计算指令

        ③ 编码上传指令

Command Buffer提交频率

        把Command Buffer暴露出来,一个好处就是它更贴近底层实际在做的事情,第二个就是我们可以直观地去控制CPU和GPU的调度。

        一个Command Buffer可以编码非常多个指令,这意味着我们可以在一帧的所有指令都放到一个Command Buffer中去编码。

        由于频繁提交Command Buffer本身是耗时的,我们可以仅在必要的时候去拆分Command Buffer。我们多次提交Command Buffer是为了GPU尽快地响应我们的任务。

单个Command Buffer和多个Command Buffer

        如图1所示,在我们不限帧的理想情况下,把一帧拆成两个Command Buffer使得整个绘制流程变得更紧凑了,GPU能够尽早地结束绘制任务,屏幕也能更快地拿到需要绘制的数据。这个越紧凑,那么拖累后续流程造成等待的情况也就越少。

        这里实际上有一个容易让人产生误会的点,因为哪怕是仅用1个Command Buffer来提交,看起来CPU和GPU的利用率也没有直观上的下降,因为GPU只是较晚开始执行当前帧的任务,并不是完全停摆了,因为在当前帧CPU还没有准备好数据时,GPU可能还在执行上一帧的任务,整个流程仅仅是“滞后”了。

        所以这里想要描述的Command Buffer数量的影响并不直接体现在利用率上,而是体现在周期上。也就是从CPU开始准备数据,到GPU结束的周期变短了,流程越短可能出现的卡顿越少。当我们后面讨论到交换链的时候,应该会对此有更好的理解。

尽可能多的Command Buffer

        再来考虑另外一个极端的现象,如果我们把Command Buffer的粒度设置的足够小,那么整个渲染周期也会变得足够短,这相当于CPU端发起一个任务,GPU端立即执行一个任务。但是我们需要考虑到渲染指令的提交本身会产生一些额外的成本,这类似于文件系统多次少量写入效率会低于少次大量写入一样。所以通常来说我们会取一个折中的数值。

        我们来看ue4引擎中对于Command Buffer拆分的设计。

        它把传输指令(Upload)和渲染指令(Graphics,Compute)分配到不同的Command Buffer中,因为我们在执行渲染前通常需要保证数据都上传了,所以在Render Command Buffer提交前,需要确保Upload Command Buffer全部提交。

        这样的话可以有效地把数据传输和图形渲染隔绝开,不过也仅仅是时序上的隔绝,ue4默认并没有添加额外的屏障,资源屏障需要我们上层正确的设置。但它也提供了相关的调试接口让我们来排除资源有效性的问题。

        Upload Command Buffer什么时候提交,实际上ue4也并没有明确,而是提供了两种选项,一个是编码完成一个资源后立即提交,这是默认的选项;另一个是等到执行渲染指令的时候再去提交前面的Upload Command Buffer。前者的弊端是在纹理流式加载的时候,帧首可能会出现大量的Submit,这个是非常耗时的;后者的弊端是资源提交的过晚会阻塞渲染任务的提交,这里如何改进是一个值得思考的地方。

        而对于其它渲染指令(Graphics,Compute),ue4默认拆分成两个Command Buffer,它首先会在Render主函数中的中间部分显式调用一次Submit,然后会在present之前强制调用一次Submit(不然无法保证正确性)。

        在ue4默认的情况下,我们会有2个绘制的Command Buffer,至少一个Upload Command Buffer,CPU端可能会有多帧数据引用不同帧的Command Buffer,那么整体的一个Command Buffer画面静止情况下是个位数,在移动的过程中会涨到两位数。

Upload Command Buffer

        我们刚刚说到默认情况下移动过程中Command Buffer会上涨,这里主要说的是Upload Command Buffer。我想对于绘制Command Buffer,由于它每帧都比较固定,大家不会有什么疑问。但是,究竟是什么情况下需要使用Upload Command Buffer呢?

        首先需要明确一个前提,虽然Vulkan提供了桌面端的支持,但是绝大部分我们使用Vulkan的场景是在移动端,也就是统一内存(unified memory)环境。

        Vulkan中资源的类型我们可以认为就是两种,Buffer和Texture,之所以这么区分是因为它们的内存排布不一样,Buffer是线性的,Texture可能是zigzag形状排列的,这是为了GPU端相邻四个像素访问的连续性。

        这里的Buffer我们根据功能来说可能有Index Buffer, Vertex Buffer, Uniform Buffer, Texture Buffer。但这些对于上传来说都不重要,重要的是这个数据是否是Voliate的,也就是说它是否是单帧使用的。

        如果它是多帧使用的,比如Index Buffer我们基本上不会去变动它,那么考虑到统一内存架构,我们在创建这个Buffer后就能直接拿到它映射后的CPU内存指针,可以直接进行内存操作,也不需要借助于Upload Command Buffer,因为我们可以在统一内存上操作,就不需要涉及到CPU到GPU的交互了。

        而对于单帧使用的场景,我们可能会把它们标记为Voliate,比较常见的就是Uniform Buffer了,因为我们会有一些shader参数需要经常更新。这里最大的区别是非Voliate的Buffer会有固定的CPU句柄,而Voliate的Buffer会映射到一些临时分配的Buffer上,这样的话可以尽可能节约一些内存空间。为了降低Voliate类型的Uniform Buffer的临时缓冲区管理成本,ue也引入了业内常见的Ring Buffer方案。

ue4中的Uniform Buffer更新

        但无论是什么类型的Buffer,在统一内存架构的情况下都是不需要Upload Command Buffer的。而纹理就比较特殊了,我们在前面说到纹理的GPU内存排布一般都不是线性的,但是我们在CPU中通常要么按行要么按列去存储纹理。这就涉及到一个转换问题,我们要保证上传到GPU的数据是正确排序的,这就需要依赖于硬件层提供的transition layout。

        对于共享内存来说,我们实际上做的事情是直接操纵GPU端最终可以访问到的内存,但问题在于我们并不知道纹理在GPU中的排布方式,因为这可能和硬件的实现有关,并没有统一的标准,所以我们没有办法通过获取CPU映射的方式直接拷贝并修改纹理布局,而只能借助于Upload Command Buffer上传,使用ImageLayoutTransition设置格式转换,调用vkCmdCopyBufferToImage完成数据的拷贝。

不同数据的更新

      因此,在这堆长篇大论之下的结论其实非常简单,目前只有纹理是需要Upload Command Buffer的。而在我们移动过程中,可能会触发纹理的streaming或新纹理的出现,这个时候才会出现Upload Command Buffer。

Command Buffer的管理

单个Command Buffer的生命周期

        上面这张官方文档的图片比较清晰地描述了单个Command Buffer的生命周期。我们在使用Command Buffer的时候,也遵循以下的调用顺序:

        ① 初始化

        vkAllocateCommandBuffers :在初始化的时候,首先需要分配内存。

        ② 编码

        vkBeginCommandBuffer,vkEndCommandBuffer:调用这两个函数来标记编码的开始和结束,我们提交Command Buffer前一定要确保编码已经完成了。

        ③ 提交

        vkQueueSubmit:提交Command Buffer需要借助于vkQueue来完成,

        ④ 回收

        vkFreeCommandBuffer,vkResetCommandBuffer:对于已经提交的Command Buffer来说,首先我们需要回收内存,然后可以重置并继续使用,如果我们选择重置的话,意味着我们不需要重新申请一个新的Command Buffer。

        重置的话我们可以单个重置,也可以整个pool进行重置,后者的性能会更好一些,但这和具体的业务情况有关,在对我们需要对Command Buffer做更精细管理的情况下,我们会去选择单个重置。

        我们来看ue4中对于Command Buffer是怎么管理的。

        对于每帧固定的提交来说,Command Buffer是可以一直复用的,比如ue4中每帧有两次固定的提交,这意味着我们只需要申请两个Command Buffer,每次使用前重置就可以了。对于上传而言,如果我们固定只在帧首上传一次,那么也只需要一个就够了。

        但是在维护Command Buffer的时候,还需要考虑一些特殊情况,比如说通常我们认为数据在帧首一次性上传是一个比较好的选择,一个是数据尽早地准备好不会影响渲染等待,另外一次性提交也会更加高效。

        假如说我们在执行渲染任务的中间,再去执行上传,这个时候如果我们Lock的是一个Buffer,我们会在渲染时序中看到一个Blit,这个传输的时间如果安排不合理的话可能会切断一些GPU的任务。

        但如果我们Lock的是一个纹理,那么这个时候由于涉及到了Upload Command Buffer,ue4会先暂停Render Command Buffer的编码,而是先去编码并立即提交这个Upload Command Buffer,因为它有可能在接下来的渲染被使用。这样的话表现就是有一个额外的提交打断了Render Command Buffer。

Blit:渲染中的数据传输

        此外,如果我们对每个上传任务单独使用一个Upload Command Buffer,那么在上传比较密集的时候,这个时候可能没有那么多Command Buffer,所以ue4就会去分配,这个时候就会产生一个耗时上的峰值。这些分配出来的Command Buffer可能只会在这一帧使用,之后如果比较长一段时间没有使用这些Command Buffer,ue4还会将它们销毁,那么这个时候又会产生一个耗时的峰值。

        对于所有的Command Buffer来说,在使用前都是需要重新分配内存,在使用完成后都需要释放内存。ue4在这里设计了两个Command Buffer数组,一个是普通的Command Buffer数组,一个是Free Command Buffer数组,这两者之间可以相互转化。

        简单来说,当我们需要使用Command Buffer的时候,就会从Free Command Buffer列表中找到一个类型匹配的,这个时候它就转化为正在使用的Command Buffer。当我们很长一段时间没有使用Command Buffer的时候,它就会释放内存并转化为Free Command Buffer。特别地,对于刚刚提交的Command Buffer,我们不能立即释放它的内存,因为这个时候可能会存在一些CPU端的引用,所以通常来说并不是理想中的第n帧的Command Buffer释放后立即给第n+1帧使用,而可能会同时存在多帧的Command Buffer,它们在几帧后才会被回收利用。

SwapChain

        Vulkan在实现绘制前必须要做的一件事情就是创建交换链,这个交换链创建之后可以一直使用,除非分辨率发生了变化或者经过了旋转。在移动设备上分辨率一般都是固定的,即使我们可能会以低分辨率渲染,最终显示到屏幕上依然会上采样到原始分辨率。

        SwapChain里面会包含了我们绘制的多个图像,这里我们称作BackBuffer,我们可以将其看作是等待显示的图像,屏幕刷新的时候会交换BackBuffer和FrontBuffer。一般来说我们会维护多个BackBuffer,ue4中的数量是3个。这样的话,在屏幕刷新的时候,我们能确保拿到的图像是完整的,这样就避免了画面的撕裂;另一方面,也可以让刷新和Buffer的填充同时进行。

        那么整个的绘制流程如下所示:

        ① 请求图像

        因为整个Swapchain最多只有3个图像,所以在绘制的时候,需要请求可用的图像,通过调用vkAcquireNextImage获取下一帧绘制图像的ImageIndex。

        这个时候如果并没有空余的图像,整个渲染流程暂时还不会停滞,因为我们仅仅是需要拿到一个ImageIndex而已。只有当渲染指令被提交到GPU后,才真正需要BackBuffer。所以这个时候我们会先同时申请一个ImageIndex空闲的信号量,我们称为信号量A。

        ue4设计了多种执行AcquireNextImage的时机。我们可以直接请求,也就是发生在比较早的时期,也可以惰性请求,直到我们第一次需要下标的时候再去申请;还有一个比较特别的是我们可以延迟请求,就是完成了渲染之后,下一帧再去请求。

        ② (可选)CopyToBackBuffer

       在我们选择延迟请求ImageIndex的时候(DelayAcquire), 我们就需要额外申请一个RenderingBackBuffer,渲染实际上是画在这个临时的RenderingBackBuffer上的。
        我们在需要使用当前帧的RenderingBackBuffer之前,才去把上一帧的RenderingBackBuffer拷贝到实际的BackBuffer中。

        这种做法会带来一些额外的带宽消耗,但是会降低我们等待可用图像的时间。

        ③ EndCommandBuffer

        结束当前的编码,准备提交。

        ④ SubmitCommandBuffer

        在执行提交前,我们需要保证GPU有可用的BackBuffer,所以这里就需要等待ImageIndex空闲的信号量A。

        提交了编码好的缓冲区指令后,可以申请一个信号量用于GPU通知CPU绘制完成,我们称为信号量B。

        ⑤ Present

        我们执行Present操作,并且对Present事件绑定GPU绘制完成的信号量B。这意味着Present也不是在调用后立即触发的,因为它至少需要等待GPU把BackBuffer填充完成才能将其显示到视口。

        在以上流程中出现了两个信号量,一个是AcquireNextImage的信号量,它可能会导致CPU的停滞。另一个是Present的信号量,它影响的是绘制,所以不会直接导致CPU的停滞,但是只有在Present完成后,BackBuffer才能得到释放。

        那么,应用程序什么时候会卡在AcquireNextImage上,也就是什么时候会出现三张图像都不可用的情况呢?这可能是CPU执行的足够快,但GPU处理得很慢,这时Present还在等待GPU完成第n帧的绘制,而CPU已经完成了n+2帧的编码。这个时候CPU就被迫停下来等待GPU。

        这个时候延迟请求ImageIndex可能会有所缓解,不过更优的做法一个是优化GPU的时间,另一个就是限帧(锁帧)和稳帧,这也是对于硬件的保护和画面流畅性的保障。

平滑帧率

          CPU和GPU之间像一条单向的流水线,也就是说CPU一直都是快于GPU的,当GPU在处理第n帧的事情时,CPU可能已经开始执行第n+1帧的数据了。这意味着更优的方式是让CPU单向控制GPU,也就是CPU端只负责提交任务,GPU端只负责执行任务。

        在这种情况下,我们一般极力避免CPU当帧回读GPU的数据,因为这相当于强制同步CPU和GPU,CPU必须等待GPU完成任务之后,才能执行下一帧的任务。

下图中,回读导致CPU和GPU并行度大幅下降

        比如上图中,CPU本来可以在GPU执行任务的时候开始下一帧的编码,却由于我们在中间发起了一个回读,导致CPU停滞等待GPU,严重影响了性能。所以我们通常会避免这样的设计,或者尽可能改成回读上一帧或者上两帧的数据。

        我们的初衷是提高CPU和GPU的利用率。我们在游戏性能测评的时候,有时候会看到这样的说法:GPU利用率高,说明榨干了GPU的性能,是优化好的一种表现,这样的说法实际上是片面的。

        每个工程的情况都会有所差异,但是不外乎就是CPU Bound或者GPU Bound,因为总会有一个跑的比另一个慢。

        完全没有Bound的情况也是存在的,那就是CPU和GPU都跑的非常快,而屏幕的刷新率是有限的,比如60Hz,也就是一秒刷新60次,这个时候我们可以认为是Display Bound。这个时候我们就可以锁帧,比如锁到60帧,这样的话整个绘制图像就是这样的比较平稳的:

锁60fps时CPU和GPU未跑满的情况

        在限帧的情况下,如果CPU和GPU都没有瓶颈,那么我们就认为在当前限帧下属于优化到位的情况。但是这个时候CPU和GPU可能都是没有完全跑满的,所以CPU和GPU的利用率不能作为优化是否到位的衡量指标。

        我们在绘制前一般会考虑两个因素,一个是稳定帧率,另一个是垂直同步。考虑垂直同步时,一般可以在逻辑层上设置60Hz和30Hz两种情况,一般会根据项目的实际帧率来设置,如果整体高于60帧,那么就可以设置为60Hz,相当于限帧到60帧;同样的,如果跑不满60帧,那么就可以设置为30Hz,相当于限帧到30帧。相当于强制让画面的渲染和屏幕显示同步。

        但如果连30帧都跑不满,那么这个时候应该也没有时间去考虑刷新的事情,当务之急是降低CPU和GPU的绝对耗时。

        由于要考虑到屏幕刷新,我们一般以屏幕刷新的时间去衡量一帧的结束,换言之就是GPU尽可能在此之前完成不多于一帧的绘制,如果离下一次显示还有很长的一段时间,那么CPU就可以先等待休息。处理不得当就可能出现丢帧的现象,也就是说GPU辛苦算出的一帧完全没有被绘制;这样的话,如果我们锁定在30Hz,那么项目有可能实际上跑不满30fps,哪怕CPU和GPU耗时并没有那么高。

        最原始的稳帧算法就是设置固定的时间间隔,每次Present之前确保和上一次Present间隔了一定长的时间,否则就在执行Present操作前等待;更加完善的做法是结合屏幕刷新时间和GPU绘制完成时间去判断在Present之前需要等待多久。

        在合理的稳帧算法下,基本上就比较难出现前面所说的卡在AcquireImageIndex的情况了,它更多地出现在不限帧或者跑不满限帧的情况,比较理想的情况下我们甚至不会用满三张BackBuffer,除非帧间插入了一个非常耗时的任务打断了这种同步。

锁帧后,CPU在submit和present之间休眠

        对于Vulkan运行的移动平台而言,减少CPU和GPU在特定时间内的工作量,可以有效地降低设备的功耗。因为无论是计算还是带宽,都会带来功耗的增加。

 内存管理

        移动平台由于Unified Memory的存在会让内存管理变得更简单,因为不需要考虑到太多CPU和GPU交互的事情,这一部分的内容在Upload Command Buffer这一节已经详细的阐述了。

        内存管理这边主要说的是这几件事,一个是内存怎么分配,另外一个是内存的可访问性。

        Vulkan这边管理内存的主要特点是子分配(SubAllocate),也就是一次申请一大块内存,然后具体使用的时候再划出一小块使用,这个做法在内存池中非常常见,这样可以避免频繁申请带来的开销。

        如果我们去截帧的话我们就会发现很多不同的Buffer共享同一个Buffer Id,只是设置了不同的偏移。

        这是因为ue4中大部分的Buffer都是从Buffer Pool分配而来的,它会维护很多分配好的Buffer,假如我们需要分配内存的对象的属性一样的话,并且内存池中还有可用的类型匹配的Buffer,那么这些数据就可能分配到同一个Buffer上。而不同类型的Buffer,比如Uniform Buffer、Index/Vertex Buffer,或者说不同访问属性,比如仅GPU可访问,以及CPU和GPU都能访问的内存,它们的对齐要求是不一样的。

        这样的设计让我们可以在不切换绑定的Buffer的情况下,通过动态偏移(dynamic offset)去访问到不同的数据,这个特性非常重要。

        而纹理和Buffer则有一些差异,纹理虽然也是SubAllocate的,但是每张纹理都是独立调用vkCreateImage创建的,因为纹理没有偏移的概念。我们只能在上层通过手动制作Atlas贴图,但由于纹理特殊的内存排布,这可能会让采样的性能下降。此外,在不少设备上会提供纹理专用内存,这意味着我们的纹理有别于Buffer,在特有内存上分配。

        接下来我们来讨论资源的访问性。

主机内存和设备内存

        一种是主机内存,我们可以理解为CPU上的内存;另一种就是设备内存,我们理解为GPU上的内存;这里我们主要讨论的还是设备内存。

        比较简单的就是只会在GPU访问的内存了(Device local),一般来说,我们只有在初始化这段Buffer的时候会执行一次Lock/Unlock去初始化它的数据,比如一些不会变的贴图,顶点数据。

       较为麻烦的是CPU也能够访问的内存,这些通常是我们需要频繁从CPU端更新的一些数据。在Unified Memory下之所以说相对简单,是源于我们对于这种需要频繁更新的数据只需要将其标记为Host Visible和Host Coherent就可以了,因为我们访问的实际上就是统一内存的数据;而对于非统一内存,如果我们想要完成GPU内存的更新,则需要借助于Staging Buffer(共享内存)来传输。

        设备内存如果是Host Visible的,那么意味着我们可以拿到它的CPU映射句柄(vkMapMemory),如果是Coherent的,意味着能够自动维持双端的一致性,如果是Cache的,意味着这段内存会缓存(手动维持双端一致性)。

        对于缓存的内存而言,如果是非统一内存,那么同一个数据在CPU和GPU上就有两份,有两份就意味着可能存在不同的现象,也就是可能会持有一份过时的缓存,众所周知,缓存是万恶之源。为了保证数据一致性,就需要在必要的时间去刷新缓存。

内存的缓存和刷新

        移动端的Vulkan开发在一块比较友好,我们在创建资源前,只需要问一问自己,创建后还会更新吗(Static还是Dynamic)?一帧临时使用还是多帧使用(是否Volatile)?虽然移动端有Unified Memory,并不意味着Staging Buffer就毫无用处,比如对于Device Local的数据,此时如果需要更新它的数据,只能借助于Staging Buffer。

Descriptor

        在Vulkan中,我们使用Descriptor来描述不同类型的资源,可以简单地把它理解为资源的引用,包括Buffer, Texture, Sampler,Uniform Buffer,Input Attachment。比如对于Texture来说,它的描述符就是ImageView,我们可以通过ViewId去唯一标识它,对于Buffer来说,它的描述符就是Buffer View,我们可以用ViewId和Offset去唯一标识它。

        Descriptor比较复杂的地方在于更新。这里涉及到一个事实就是,假如Descriptor引用的资源如果更新,那么View也需要重新创建,这两者的生命周期是强绑定的,毕竟View是作为引用存在的。由于这一部分内容涉及到了绑定,我会在接下来的一个章节阐述这一点。

Descriptor Set

        我认为资源绑定是Vulkan中比较重要的一个环节,比如说dx12的资源绑定设计的就比较复杂,不得不说Root Signature,Resource Table这一堆奇奇怪怪的名词非常的劝退。Vulkan的资源绑定看上去会更加纯粹一些,因为它就是不同Descriptor的集合,也就是只有一个Descriptor Set。

        你可以认为Vulkan的设计就是万物皆数据,什么渲染指令的提交?就是往GPU上传存储指令的缓冲区;什么又是资源绑定?也就是往GPU上传存储资源句柄的缓冲区。

        因为我们会把单个资源的句柄称作描述符(Descriptor),那么一次绑定的所有资源就是描述符集(Descriptor Set),描述符集的每个描述符的具体类型我们使用描述符集布局(Descriptor Set Layout)来概述。

       

        Descriptor Set本身的概念比较简单,它比较麻烦的我们如何去管理Descriptor Set,一个比较直接的做法就是为每个drawcall分配一个独立的Descriptor Set。这个做法下,一个是Descriptor的数量会随着drawcall的增加而增加,另一个是调用Allocate DescriptorSet/Update Descriptor的频率会增加,而实测下来会发现Allocate/Update DS的调用在CPU端上非常耗时。

        既然我们知道Descriptor Set本质上是一个记录资源句柄的缓冲区,那这就意味着如果两个不同的drawcall如果使用了相同的资源,那它们可以共享Descriptor Set;另一方面,如果前后帧同一个drawcall的资源绑定没有发生变化,这个Descriptor Set也是可以复用的。针对Descriptor Set复用的优化我们称为Descriptor Set Cache。

        对于后者来说我想比较好理解,对于静态材质的物件,它的资源基本上是不变的,或者说我们只要确保它绑定资源的id和offset不发生变化即可。

        而对于前者来说,不同drawcall绑定相同资源这件事情看起来有些难以实现,即使是两个使用了相同材质实例的物件,最起码它们的一些私有Uniform Buffer是不一样的(比如记录位置信息的Primitivie Uniform Buffer)。

        针对这种情况,我们可以考虑使用Dynamic Offset来优化,也就是先确保Uniform都分配在同一个Buffer上,这样Id就是一致的,Descriptor Set中记录的Offset设置为0,直到调用BindDescriptorSet后才去指定Dynamic Offset。

        这样的话,使用的Uniform不一样,但其它资源(纹理、采样器)一样的物件,都可以共享Descriptor Set了,这就大大提高了Descriptor Set的复用率,动态偏移的使用仅仅会对GPU带来一些非常小的影响。

        

        我们刚刚讨论了Descriptor Set Cache,并描述了它在不同drawcall之间的复用,那么它在不同帧之间的复用又是怎样的一个情况呢?

        在shader编程中,我们应该会发现资源输入是比较死板的,我们必须在编写特定shader的时候,就把所有输入格式以及对应的插槽位置都指定好。如果有些数据是不定长的,那么我们也必须指定一个上限,有些纹理只在有些情况下需要去访问,我们也必须传输一个内容,这个时候就会出现blackdummy/whitedummy这样奇怪的贴图。既然这些数据在GPU中都是可见的,我为什么不能自由的决定访问哪些内容呢?在这样的疑问下,bindless出现了,不过这并不是我们今天讨论的重点。

        在这样“固定”输入格式的情况下,Descriptor Set唯一可能发生变化的情况就是Descriptor本身发生了变化。

        我们来看不同的情况,对于Uniform Buffer来说,如果我们开启了Dynamic Offset,对于Volatile类型的,我们使用Ring Buffer更新,只要Ring Buffer的句柄没有发生变化就能够复用,这意味着即使我们使用动态材质更新了一些常量,可能并不会影响缓存;而对于非Volatile类型的,我们借助staging buffer进行拷贝,这也没有破坏句柄。

        同理,对于Buffer,由于我们可以直接拿到CPU映射更新,也不会让Descriptor变化;除非我们切换了绑定的Buffer对象,或者说我们把Buffer指定为Volatile的。

        对于纹理而言,就会稍微复杂点,除了我们主动在逻辑层去切换的纹理,还有Texture Mipmap Steaming机制也会影响TextureView,这个机制简单来说,就是虽然GPU会为我们自动计算合适的mipmap,但在物件比较远的时候,用到的mipmap一般都是比较高级别的,这个时候去全部加载mipmap就会占用过多的内存,这个时候我们就可以只加载到最低级别的mipmap。这实际上就是一个牺牲运行时效率去换取内存占用的优化,如果我们在游戏中看到有贴图突然由模糊变得清晰,这就是texture streaming机制在加载。

        mipmap在运动中可能会发生切换,这个切换不仅会生成新的Upload Command Buffer,还会导致纹理的句柄发生变化,Texture View重新创建,进而破坏Descriptor Set Cache。

        所以我们会发现,在运动过程相比起静止过程会更卡,逻辑层上它可能在加载新的内容,做一些缓存的计算更新;渲染层上它可能由于新的物件进入视野或者资产发生变化,在做一些渲染数据的更新。

        还有一个值得探讨的问题,那就是一个drawcall实际上可以绑定多个Descriptor Set,我们应该如何把数据拆分到不同Descriptor Set。像Vulkan的一些教程中可能会给出这样的建议,那就是按照资源的更新频率去设置,比如所有物件共享、逐材质共享、逐物件专享的数据分别放到不同的DS里,这样可以尽可能复用公共数据。

        这种建议实际上和ue4最终采取的做法差的比较远,ue4这里要么把所有数据都放到同一个Descriptor Set里,要么可以选择按照vertex shader和pixel shader拆开放到两个Descriptor Set,它也提供了公共数据拆成独立的Descriptor Set的可选项。这有可能和移动端支持最多绑定的Descriptor Set数量比较少有关(最多4个),使得基于DS拆分的设计没有什么施展的空间。

        像vs和ps分开存储的做法,有一个好处就是对于使用了相同母材质,不同材质实例(可能切换了纹理)的物件来说,vs的Descriptor Set有可能是可以复用的,因为变化的纹理大部分都是在ps里采样的。而vs和ps合并存储的话,一些vs和ps共享的数据就不用记录两遍,整体Buffer的大小可能会小于vs和ps加起来的大小。

        我们创建Descriptor Set,一般是从Descriptor Pool中去创建的。如果是走了Descriptor Set Cache的对象,我们可以用多个DS共用一个Pool去分配,在Pool不够用时通过增加Pool数量的方式去扩容。

        而如果没有走Descriptor Set Cache,我们通常认为绑定中含有Volatile的资源(主要是Buffer,我们不考虑Uniform和texture),这就意味着资源会每帧更新,这样的话,Descriptor Set Cache在不同帧之间一定无法复用,所以就没有必要去走Descriptor Set Cache了。

        对于这类不走缓存的Descriptor Set,ue4会为其维护一个独立的Descriptor Pool,这样的话,每帧就会反复产生创建Descriptor Pool和重置Descriptor Pool产生的开销,如果我们在截帧工具中在一帧结束的时候看到大量ResetDescriptorPool的调用,意味着没有走Descriptor Set Cache的drawcall比较多,如果我们看到绘制中大量AllocateDescriptorSet和UpdateDescriptorSet的调用,意味着没有命中Descriptor Set Cache的drawcall比较多。

        默认情况下,ue4为所有Graphics的绘制开启了Descriptor Set Cache,而对Compute默认不执行,因为它认为Compute大概率会有Volatile的资源,因此我们在profile的过程中会发现Compute的性能可能总是要差一点,有一部分的问题可能就出在这里。

渲染调用

        在完成一次drawcall前,我们通常需要:

        ① bind pipeline state

        ② bind index buffer, vertex buffer

        ③ bind descriptor set

        ④ set render state (viewport, scissor等)

        ⑤ draw

        大部分渲染状态量都封装在pipeline state中,大部分参数都封装在descriptor set中,但需要注意的是ib和vb的设置是独立于descriptor set的。

        对于Vulkan而言,它所做的事情可以大致分为两个阶段,一个是准备并更新资源(比如更新纹理、Buffer、Uniform Buffer等数据),这个事情一般都是在渲染开始前完成的,这样的话可以不阻塞后续的渲染流程。

        第二个阶段就是执行drawcall了,每个drawcall所做的事情就是完成上述的绑定,drawcall数量越多,理论上CPU的耗时也会越高。

        由于图形管线的状态机机制,在数据没有发生变化的时候,我们不需要重新绑定,比如第n个drawcall和第n+1个drawcall使用的是相同的pipeline,那么我们可以不重复绑定pipeline。因此良好的排序可以让我们更好地利用状态的缓存。

        实际上绑定本身,也就是调用bindxxx的消耗并不算太高,比较麻烦的是绑定对象的创建。比如pipeline state的创建就非常耗时,所以我们通常都选择预收集pso的做法。

        另一方面,我们可能会听到过这样的说法,CPU中资源绑定是非常耗时的。这里比较耗时的实际上是准备资源绑定的buffer,也就是我们前面说的allocateDS和updateDS。

        这个记录资源绑定的buffer对于传统图形API而言是隐藏起来的,因此它可能无条件地为每次drawcall都创建并填充新的buffer,从感官上就会觉得资源绑定非常耗时。而Vulkan层开放了descriptor set后,我们就有办法显式复用这个绑定buffer,从而降低资源绑定的消耗。

        从优化的角度来看,带给我们的启示就是:

        ① 合理排序,复用状态设置,合理设计架构去规避冗余绑定

        ② 复用descriptor set

        ③ 减少每个shader绑定的内容,降低单个descriptor set更新的时间

Barrier

        Barrier是对于资源安全性的保护。因为GPU在执行任务的时候,只要线程是空闲的,就会从队列中取出下一个任务执行,而不会考虑资源的可用性,因此这个依赖关系需要我们人为指定告知GPU。

        GPU一般都是按照一定顺序执行,看起来似乎不会出现资源不可用的现象,比如我们一般都会在前一个pass写入数据,在后面的pass读取数据,理论上在后面pass读取数据的时候资源应该准备好了。但实际上由于GPU中复杂的缓存机制,我们不能保证读到的数据就是最新的,因此barrier是必要存在的。

        另一方面,一些GPU硬件中可能会支持多个硬件插槽,比如vertex, fragment, compute这三个计算单元可能是独立的,那么这三者是可以并行执行任务的。这时候,假设前一个pass的fragment正在执行,此时vertex插槽可能就会去取下一个pass的任务开始执行,如果不幸这个vertex需要访问上一个fragment写入的数据,而没有正确设置屏障,这个时候可能会发生device lost。在这个例子中,如果屏障正确设置,那就意味着vertex和fragment没有办法并行,GPU执行效率下降,也就是说屏障会带来安全,但更优的设计是避免屏障导致的等待,比如这里就可以调整渲染顺序,把vertex中读取数据的pass安排在和它没有ps->vs的依赖关系的pass之后。

        Vulkan中包括执行屏障和内存屏障,前者只保证了顺序的先后,后者能够保证内存读写的安全性,内存屏障根据内存对象的不同又分为全局屏障、纹理屏障和缓冲区屏障。

        我们在设置Barrier的时候,通常会去指定访问形式(Access),比如读或者写,还有访问阶段(Stage),比如vertex,pixel,compute,这个粒度可以到某个stage的前后,比如执行vertex之前。

        Access和Stage的设置通常需要我们指定开始和结束的阶段,这个阶段范围越精确,比如说开始的越晚,结束的越早,那么它可能带来阻塞的概率就会下降。如果设置得不够准确,可能会导致一些不必要的等待,降低GPU的性能。

        ue4中对于屏障的设置就是相对宽泛的,也就是说它确保了正确性,但并没有确保最优性。我们在渲染逻辑编写的时候,ue4也开放了Transition的设置,但是它开放的参数并不多,我们只能描述一下资源和它大致所处的阶段,而无法直接设置Access和Stage。而ue4的翻译对于Access和Stage的设置都是以性能不一定好但绝不会出错为原则来设置的。ue4最新的RDG系统甚至让我们省去了Transition设置的工作,但是这样的二次封装也让barrier的冗余变得更严重。

        barrier这个东西本身设置有开销,但更为严重的一个是barrier本身设置错误导致的异常访问,另一个是barrier设置的过于宽松带来的gpu stall。但barrier只是一种保护,所以大部分情况下如果资源是安全的,并不会触发stall,所以我们可以仅在出现了明显不合理设置导致停滞时再去考虑优化。
        compute阶段的屏障有一些比较特别的地方,因为假如我们连续请求多个compute任务,它在GPU中可以合并到一个Surface中,也就是说不同的compute任务是可以并行执行的,除非它们之间有依赖关系,比如下一个compute需要读取上一个compute的UAV。这就是compute的优势所在,它既可以和外部并行,也可以在内部并行(但并行情况可能会因机型而异,有些可能不支持)

        如果我们需要强制不同任务不能并行,这个时候执行屏障就能够派上用场,ue4对于compute中的UAV会统一翻译成全局屏障。

RenderPass和SubPass

        Render Pass是我们在Vulkan层指定的,一般来说如果切换了Render Target,通常就需要我们切换Render Pass。每个pass结束后我们会把数据写入到系统内存,这就会产生带宽消耗,所以我们当然是期望pass越少越好的,这里的一个Pass就会对应GPU中的一个Surface。

        我们应该都知道移动平台有Tile Memory这样的机制,对于一个Pass来说会自动选择Direct或是Tile的执行形式。如果选择了Tile的绘制形式,那么会根据设备的情况和渲染的thread memory负载来计算拆分成多少个Tile来执行。在实际的绘制中,会先完成Binning阶段(顶点处理),再进入Rendering阶段,逐个Tile执行Render,每个Tile执行完成后直接把当前Tile的内容写入到系统内存。

        所以我们看到的一个Surface内,GPU执行顺序就是先把所有顶点处理完,再去一块块的绘制。

单个Surface的执行顺序

        也正是因为有了Tile Memory,我们可以引入subpass的概念,如果一些数据仅仅作为下一个pass相同像素位置的输入,并不需要实际写入系统内存,最终输出的Render Target是固定的,那么我们就可以把这几个步骤做为subpass,避免切换Render Target和Load Store带来的消耗。

        假如我们引入了subpass,那么GPU上的绘制顺序时,就会先去执行第一个Tile的多个subpass,再去执行第二个Tile的多个subpass,表现上还是一块块的画完。

带有subpass的Surface的执行顺序

        ue4对subpass的封装并没有那么彻底,这意味着假如我们增加删减subpass,都可能需要改动到Vulkan层的一些Layout的东西。

        subpass可以设置的内容还是比较丰富的,因为不同subpass可能会有自己的输入输出,所以我们可以去设置InputAttachments和OutputAttachments;另外比较重要的就是可以去设置subpass中Attachment的屏障,类似资源的barrier,我们可以指定Stage和Access属性。对于存在数据依赖的subpass之间屏障如果设置不正确,那么就很可能会发生严重的渲染错误甚至崩溃。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/98720.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

“揭秘淘宝店铺所有商品接口:一键获取海量热销宝贝信息!“

淘宝店铺所有商品接口可以通过shop id或店铺主链接获取到整店商品,数据包括:商品ID,图片地址,店铺标题,优惠价,价格,销量,宝贝链接等整个店铺的商品。 要使用这个接口,需…

抄写Linux源码(Day17:你的键盘是什么时候生效的?)

回忆我们需要做的事情: 为了支持 shell 程序的执行,我们需要提供: 1.缺页中断(不理解为什么要这个东西,只是闪客说需要,后边再说) 2.硬盘驱动、文件系统 (shell程序一开始是存放在磁盘里的,所以需要这两个东…

使用弹性盒子flex对html进行布局和动态计算视口高度

使用弹性盒子flex对html进行布局的一个练习 height: calc(100vh - 4px); # vh表示视口高度的百分比,所以100vh表示整个视口的高度。 .mytxt { text-indent: 2em; /* 首航缩进2字符 */ line-height: 2; /* 2倍行高 */ padding: 8px; /* 内容与边框的距离 */ } …

策略模式优雅实践

1 什么是策略模式 策略模式(Strategy Pattern)是一种常用的面向对象设计模式,它定义了一系列可互相替换的算法或策略,并将每个算法封装成独立的对象,使得它们可以在运行时动态地替换。具体来说,策略模式定义…

Flutter学习笔记

此篇文章用来记录学习Flutter 和 Dart 相关知识 零.Dart基本数据类型 Dart 是一种静态类型的编程语言,它提供了一系列基本数据类型,用于存储和操作不同种类的数据。以下是 Dart 中的一些基本数据类型以及它们的详细介绍: 1. 整数类型&#…

爱国者的润学日记-十月

首先需要科学的准备面试和润。如何进行科学的准备工作呢? 高效的按照面试考察内容进行针对性训练,按 Machine-learning-interview准备保证处于专注的心态,如今互联网娱乐发达,之前即使比赛时我也是一边比赛一边看视频。之后准备面…

L1-035 情人节 c++解法

题目再现 以上是朋友圈中一奇葩贴:“2月14情人节了,我决定造福大家。第2个赞和第14个赞的,我介绍你俩认识…………咱三吃饭…你俩请…”。现给出此贴下点赞的朋友名单,请你找出那两位要请客的倒霉蛋。 输入格式: 输入…

3.2.OpenCV技能树--二值图像处理--图像腐蚀与膨胀

文章目录 1.文章内容来源2.图像膨胀处理2.1.图像膨胀原理简介2.2.图像膨胀核心代码2.3.图像膨胀效果展示 3.图像腐蚀处理3.1.图像腐蚀原理简介3.2.图像腐蚀核心代码3.3.图像腐蚀效果展示 4.易错点总结与反思 1.文章内容来源 1.题目来源:https://edu.csdn.net/skill/practice/o…

探秘小米增程汽车与仿生机器人的未来:AI大模型的潜在影响及苹果iPhone15Pro发热问题解决之道

🌷🍁 博主猫头虎 带您 Go to New World.✨🍁 🦄 博客首页——猫头虎的博客🎐 🐳《面试题大全专栏》 文章图文并茂🦕生动形象🦖简单易学!欢迎大家来踩踩~🌺 &a…

现货白银图表分析的依据

现货白银的行情图表分析其实与股票的差不多,投资者可以结合均线、k线的变化,来分析实时的行情走势。当走势图的均线呈多头排列,即短期、中期、长期均线依次从上到下排列并向右上方运行,且白银价格沿各均线向右上方拉升&#xff0c…

一文熟练使用python修改Excel中的数据

使用python修改Excel中的内容 1.初级修改 1.1 openpyxl库的功能: openpyxl模块是一个读写Excel 2010文档的Python库,如果要处理更早格式的Excel文档,需要用到额外的库,例如Xlwings。openpyxl是一个比较综合的工具,能…

访问Apache Tomcat的虚拟主机管理页面

介绍 通过Tomcat Host Manager应用可以创建、删除、管理Tomcat内的虚拟主机&#xff08;virtual hosts&#xff09;。该应用是Tomcat安装的一部分&#xff0c;默认在<Tomcat安装目录>/webapps/host-manager&#xff1a; 配置用户名、密码、角色 要访问Host Manager应…

鉴源实验室 | AUTOSAR SecOC:保障汽车通信的安全

作者 | 沈平 上海控安可信软件创新研究院汽车网络安全组 来源 | 鉴源实验室 社群 | 添加微信号“TICPShanghai”加入“上海控安51fusa安全社区” 在现代汽车行业中&#xff0c;随着电子控制单元&#xff08;ECUs&#xff09;的普及以及车与车之间通信的不断增加&#xff0c;确…

《spring security in action》读书笔记

1. why spring security 是强大的高度可定制的 身份验证 和 访问控制 应用级框架。 常见的漏洞包含&#xff1a; 身份验证失效&#xff0c;会话固定&#xff0c;跨站脚本xss请求伪造&#xff0c;CSRF注入敏感数据泄漏缺乏方法访问控制。 身份验证失效&#xff1a; 不能仅仅验…

[尚硅谷React笔记]——第3章 React应用(基于React脚手架)

目录&#xff1a; react脚手架创建项目并启动react脚手架项目结构一个简单的Hello组件样式的模块化功能界面的组件化编码流程&#xff08;通用&#xff09;组件的组合使用-TodoList 1.react脚手架 xxx脚手架: 用来帮助程序员快速创建一个基于xxx库的模板项目 包含了所有需…

/lib64/libstdc++.so.6: version `GLIBCXX_3.4.21‘ not found (required by

在某项目中遇到下面的错误&#xff0c; ./model2trt_v2: /lib64/libstdc.so.6: version GLIBCXX_3.4.21 not found (required by ./model2trt_v2) ./model2trt_v2: /lib64/libstdc.so.6: version GLIBCXX_3.4.21 not found (required by ../../../lib/linux_lib/libcuda_utils…

QT之可自由折叠和展开的布局

介绍和功能分析 主要是实现控件的折叠和展开&#xff0c;类似抽屉控件&#xff0c;目前Qt自带的控件QToolBox具有这个功能&#xff0c;但是一次只能展开一个&#xff0c;所以针对自己的需求可以自己写一个类似的功能&#xff0c;这里实现的方法比较多&#xff0c;其实原理也比较…

2023.10.02 win7x64sp1下Navicat_Premium15_x86连接Oracle_10g(安装在win2003x86)

Oracle_10g安装在这个版本的系统里: Microsoft Windows [版本 5.2.3790] 这个win2003_x86(分配内存1G)安装在vmware虚拟机里. 安装包文件名为:oracle 10g_win32.zip 大小约624 MB (655,025,354 字节) 安装完毕后,tcp1521端口应该开放: Microsoft Windows [版本 5.2.3790]…

Matlab参数估计与假设检验(举例解释)

参数估计分为点估计和区间估计&#xff0c;在matlab中可以调用namefit()函数来计算参数的极大似然估计值和置信区间。而数据分析中用得最多的是正态分布参数估计。 例1 从某厂生产的滚珠中抽取10个&#xff0c;测得滚珠的直径&#xff08;单位&#xff1a;mm&#xff09;为x[…

Git入门详解

Git入门详解 本文承接上文 Git入门简介 并做了内容扩充。本文讲述Git工具的安装、配置及使用友情参考链接&#xff1a;https://gitee.com/all-about-git 1. Git安装 安装官网&#xff1a;https://git-scm.com/安装过程如下&#xff1a; 双击.exe默认安装即可 2. Git配置 …