[引擎开发] 杂谈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尽快地响应我们的任务。
如图1所示,在我们不限帧的理想情况下,把一帧拆成两个Command Buffer使得整个绘制流程变得更紧凑了,GPU能够尽早地结束绘制任务,屏幕也能更快地拿到需要绘制的数据。这个越紧凑,那么拖累后续流程造成等待的情况也就越少。
这里实际上有一个容易让人产生误会的点,因为哪怕是仅用1个Command Buffer来提交,看起来CPU和GPU的利用率也没有直观上的下降,因为GPU只是较晚开始执行当前帧的任务,并不是完全停摆了,因为在当前帧CPU还没有准备好数据时,GPU可能还在执行上一帧的任务,整个流程仅仅是“滞后”了。
所以这里想要描述的Command Buffer数量的影响并不直接体现在利用率上,而是体现在周期上。也就是从CPU开始准备数据,到GPU结束的周期变短了,流程越短可能出现的卡顿越少。当我们后面讨论到交换链的时候,应该会对此有更好的理解。
再来考虑另外一个极端的现象,如果我们把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方案。
但无论是什么类型的Buffer,在统一内存架构的情况下都是不需要Upload Command Buffer的。而纹理就比较特殊了,我们在前面说到纹理的GPU内存排布一般都不是线性的,但是我们在CPU中通常要么按行要么按列去存储纹理。这就涉及到一个转换问题,我们要保证上传到GPU的数据是正确排序的,这就需要依赖于硬件层提供的transition layout。
对于共享内存来说,我们实际上做的事情是直接操纵GPU端最终可以访问到的内存,但问题在于我们并不知道纹理在GPU中的排布方式,因为这可能和硬件的实现有关,并没有统一的标准,所以我们没有办法通过获取CPU映射的方式直接拷贝并修改纹理布局,而只能借助于Upload Command Buffer上传,并调用vkCmdCopyBufferToImage完成格式的转换。
因此,在这堆长篇大论之下的结论其实非常简单,目前只有纹理是需要Upload Command Buffer的。而在我们移动过程中,可能会触发纹理的streaming或新纹理的出现,这个时候才会出现Upload 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。
此外,如果我们对每个上传任务单独使用一个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的利用率。我们在游戏性能测评的时候,有时候会看到这样的说法:GPU利用率高,说明榨干了GPU的性能,是优化好的一种表现,这样的说法实际上是片面的。
每个工程的情况都会有所差异,但是不外乎就是CPU Bound或者GPU Bound,因为总会有一个跑的比另一个慢。
完全没有Bound的情况也是存在的,那就是CPU和GPU都跑的非常快,而屏幕的刷新率是有限的,比如60Hz,也就是一秒刷新60次,这个时候我们可以认为是Display Bound。这个时候我们就可以锁帧,比如锁到60帧,这样的话整个绘制图像就是这样的比较平稳的:
在限帧的情况下,如果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,除非帧间插入了一个非常耗时的任务打断了这种同步。
对于Vulkan运行的移动平台而言,减少CPU和GPU在特定时间内的工作量,可以有效地降低设备的功耗。因为无论是计算还是带宽,都会带来功耗的增加。
内存管理
移动平台由于Unified Memory的存在会让内存管理变得更简单,因为不需要考虑到太多CPU和GPU交互的事情,这一部分的内容在Upload Command Buffer这一节已经详细的阐述了。
内存管理这边主要说的是这几件事,一个是内存怎么分配,另外一个是内存的可访问性。
Vulkan这边管理内存的主要特点是子分配(SubAllocate),也就是一次申请一大块内存,然后具体使用的时候再划出一小块使用,这个做法在内存池中非常常见,这样可以避免频繁申请带来的开销。
如果我们去截帧的话我们就会发现很多不同的Buffer共享同一个Buffer Id,只是设置了不同的偏移,甚至不同类型的Buffer我们也可以分配到同一个物理Buffer上,因为对于GPU来说它们在存储形式上并没有太大差异。
这样的设计让我们可以在不切换绑定的Buffer的情况下,通过动态偏移(dynamic offset)去访问到不同的数据,这个特性非常重要。
接下来我们来讨论资源的访问性。
一种是主机内存,我们可以理解为CPU上的内存;另一种就是设备内存,我们理解为GPU上的内存;这里我们主要讨论的还是设备内存。
比较简单的就是只会在GPU访问的内存了(Device local),一般来说,我们只有在初始化这段Buffer的时候会执行一次Lock/Unlock去初始化它的数据,比如一些不会变的贴图,顶点数据。
较为麻烦的是CPU也能够访问的内存,这些通常是我们需要频繁从CPU端更新的一些数据。在Unified Memory下之所以说相对简单,是源于我们对于这种需要频繁更新的数据只需要将其标记为Host Visible和Host Coherent就可以了,因为我们访问的实际上就是统一内存的数据。
但是对于非统一内存就比较麻烦,因为需要借助于共享内存来传输。同一个数据在CPU和GPU上有两份,有两份就意味着可能存在不同的现象,也就是可能会持有一份过时的缓存,众所周知,缓存是万恶之源。为了保证数据一致性,就需要在必要的时间去刷新缓存。
好在移动端的Vulkan开发在一块比较友好,我们在创建资源前,只需要问一问自己,创建后还会更新吗(Static还是Dynamic)?一帧临时使用还是多帧使用(是否Volatile)?
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执行顺序就是先把所有顶点处理完,再去一块块的绘制。
也正是因为有了Tile Memory,我们可以引入subpass的概念,如果一些数据仅仅作为下一个pass相同像素位置的输入,并不需要实际写入系统内存,最终输出的Render Target是固定的,那么我们就可以把这几个步骤做为subpass,避免切换Render Target和Load Store带来的消耗。
假如我们引入了subpass,那么GPU上的绘制顺序时,就会先去执行第一个Tile的多个subpass,再去执行第二个Tile的多个subpass,表现上还是一块块的画完。
ue4对subpass的封装并没有那么彻底,这意味着假如我们增加删减subpass,都可能需要改动到Vulkan层的一些Layout的东西。
subpass可以设置的内容还是比较丰富的,因为不同subpass可能会有自己的输入输出,所以我们可以去设置InputAttachments和OutputAttachments;另外比较重要的就是可以去设置subpass中Attachment的屏障,类似资源的barrier,我们可以指定Stage和Access属性。对于存在数据依赖的subpass之间屏障如果设置不正确,那么就很可能会发生严重的渲染错误甚至崩溃。
相关文章:
[引擎开发] 杂谈ue4中的Vulkan
接触Vulkan大概也有大半年,概述一下自己这段时间了解到的东西。本文实际上是杂谈性质而非综述性质,带有严重的主观认知,因此并没有那么严谨。 使用Vulkan会带来什么呢?简单来说就是对底层更好的控制。这意味着我们能够有更多的手段…...
docker--redis容器部署及地理空间API的使用示例-II
文章目录 Redis 地理位置类型API命令操作示例JAVA使用示例导入依赖RedisTemplate 操作GeoData示例CityInfo实体类Geo操作接口类Geo操作接口实现类SpringBoot测试类RedissonClient 操作GeoData示例docker–redis容器部署及与SpringBoot整合 docker–redis容器部署及地理空间API的…...
Vue中如何进行文件浏览与文件管理
Vue中的文件浏览与文件管理 文件浏览与文件管理是许多Web应用程序中常见的功能之一。在Vue.js中,您可以轻松地实现文件浏览和管理功能,使您的应用程序更具交互性和可用性。本文将向您展示如何使用Vue.js构建文件浏览器和文件管理功能,以及如…...
jenkins利用插件Active Choices Plug-in达到联动显示或隐藏参数,且参数值可修改
1. 添加组件 Active Choices Plug-in 如jenkins无法联网,可在以下两个地址中下载插件,然后放到/home/jenkins/.jenkins/plugin下面重启jenkins即可 Active Choices Active Choices | Jenkins plugin 2. 效果如下: sharding为空时…...
香蕉叶病害数据集
1.数据集 第一个文件夹为数据增强(旋转平移裁剪等操作)后的数据集 第二个文件夹为原始数据集 2.原始数据集 Cordana文件夹(162张照片) healthy文件夹(129张) Pestalotiopsis文件夹(173张照片&…...
天地无用 - 修改朋友圈的定位: 高德地图 + 爱思助手
1,电脑上打开高德地图网页版 高德地图 (amap.com) 2,网页最下一栏,点击“开放平台” 高德开放平台 | 高德地图API (amap.com) 3,在新网页中,需要登录高德账户才能操作。 可以使用手机号和验证码登录。 4,…...
AtCoder Beginner Contest 232(A-G)
A - QQ solver (atcoder.jp)直接按题意模拟即可。 B - Caesar Cipher (atcoder.jp)按题意模拟即可 C - Graph Isomorphism (atcoder.jp)按题意模拟即可 D - Weak Takahashi (atcoder.jp) 一个非常套路的网格dp E - Rook Path (atcoder.jp) (1)题意 有…...
计算机网络(第8版)-第5章 运输层
5.1 运输层协议概述 5.1.1 进程之间的通信 图5-1 中两个运输层之间有一个深色双向粗箭头,写明“运输层提供应用进程间的逻辑通信”。 图5-1 运输层为相互通信的应用进程提供了逻辑通信 5.1.2 运输层的两个主要协议 5.1.3 运输层的端口 请注意,这种…...
AtCoder Beginner Contest 231(D-F,H)
D - Neighbors (atcoder.jp) (1)题意 给出M组关系,问是否有一个排列,能表示A[i]和B[i]相邻 (2)思路 考虑如果有环,显然不能满足排列,因为排列中度数最多为2,若有超过2的显…...
【Python】map
map()函数是Python内置函数之一,它的主要作用是将一个函数应用于可迭代对象中的每个元素,并返回一个包含结果的迭代器。 map()函数的语法如下: map(function, iterable)function参数是一个函数,表示要应用于可迭代对象每个元素的…...
Swift 5.9 与 SwiftUI 5.0 中新 Observation 框架应用之深入浅出
0. 概览 Swift 5.9 一声炮响为我们带来全新的宏(Macro)机制,也同时带来了干霄凌云的 Observation 框架。 Observation 框架可以增强通用场景下的使用,也可以搭配 SwiftUI 5.0 而获得双剑合璧的更强威力。 在本篇博文,…...
【已解决】在 Vite 项目中使用 eslint-config-ali 时遇到的解析错误
错误还原 搭建 Vite 项目 pnpm create vite my-vue-app --template vue-ts安装 eslint-config-ali pnpm i -D eslint-config-ali typescript-eslint/parser typescript-eslint/eslint-plugin eslint-plugin-import eslint-import-resolver-typescript vue-eslint-parser esl…...
蓝桥杯每日一题2023.10.5
3420. 括号序列 - AcWing题库 题目描述 题目分析 对于这一我们需要有前缀知识完全背包 完全背包的朴素写法: #include<bits/stdc.h> using namespace std; const int N 1010; int n, m, v[N], w[N], f[N][N]; int main() {cin >> n >> m;fo…...
PyTorch实例:简单线性回归的训练和反向传播解析
文章目录 🥦引言🥦什么是反向传播?🥦反向传播的实现(代码)🥦反向传播在深度学习中的应用🥦链式求导法则🥦总结 🥦引言 在神经网络中,反向传播算法…...
Arcgis提取玉米种植地分布,并以此为掩膜提取遥感影像
Arcgis提取玉米种植地分布上,并以此为掩膜提取遥感影像 一、问题描述 因为之前反演是整个研究区,然而土地利用类型有很多类,只在农田或者植被上进行反演,需要去除水体、建筑等其他类型,如何处理得到下图中只有耕地类…...
软件工程与计算总结(四)项目管理基础
目录 一.项目和项目管理 二.团队组织与管理 三.软件质量保障 四.软件配置管理 五.项目实践 一.项目和项目管理 1.软件开发远不是纯粹的编程,随着软件规模的增长,软件开发活动也变得越来越复杂~ 2.软件项目就是要将所有的软件开发活动组织起来&#…...
【Python】datetime 库
# timedelta(days, seconds, microseconds,milliseconds, minutes, hours, weeks) 默认按顺序传递参数 # 主要介绍 datetime.datetime 类 # 引入 from datetime import datetime today datetime.now() # 获取当前时间 2023-10-05 15:58:03.218651 today1 datetime.utcnow() #…...
从0开始python学习-28.selenium 需要图片验证的登录
url https://test.com/login driver.get(url) # 获取登录页面需要输入账号密码进行模拟登录操作 user driver.find_element(By.XPATH,//*[id"login"]/div[2]/div/form[2]/div[2]/div/div/input).send_keys(username) pwd driver.find_element(By.XPATH,//*[id&qu…...
Nginx搭建Rtmp流媒体服务,并使用Ffmpeg推流
文章目录 1.rtmp流媒体服务框架图2.nginx配置3.配置nginx4.使用ffmpeg推流5.实时推摄像头流 本项目在开发板上使用nginx搭建流媒体服务,利用ffmpeg进行推流,在pc上使用vlc media进行拉流播放。 1.rtmp流媒体服务框架图 2.nginx配置 下载:wge…...
IDEA 将一个普通Java工程转化为maven工程
打开IntelliJ IDEA并打开Java工程。 在项目窗口中,右键单击项目名称,选择“Add Framework Support”。 在弹出的窗口中,选择“Maven”。 在“Maven Information”窗口中,填写Group Id、Artifact Id和Version等基本信息。 点击…...
7.4.分块查找
一.分块查找的算法思想: 1.实例: 以上述图片的顺序表为例, 该顺序表的数据元素从整体来看是乱序的,但如果把这些数据元素分成一块一块的小区间, 第一个区间[0,1]索引上的数据元素都是小于等于10的, 第二…...
Flask RESTful 示例
目录 1. 环境准备2. 安装依赖3. 修改main.py4. 运行应用5. API使用示例获取所有任务获取单个任务创建新任务更新任务删除任务 中文乱码问题: 下面创建一个简单的Flask RESTful API示例。首先,我们需要创建环境,安装必要的依赖,然后…...
springboot 百货中心供应链管理系统小程序
一、前言 随着我国经济迅速发展,人们对手机的需求越来越大,各种手机软件也都在被广泛应用,但是对于手机进行数据信息管理,对于手机的各种软件也是备受用户的喜爱,百货中心供应链管理系统被用户普遍使用,为方…...
盘古信息PCB行业解决方案:以全域场景重构,激活智造新未来
一、破局:PCB行业的时代之问 在数字经济蓬勃发展的浪潮中,PCB(印制电路板)作为 “电子产品之母”,其重要性愈发凸显。随着 5G、人工智能等新兴技术的加速渗透,PCB行业面临着前所未有的挑战与机遇。产品迭代…...
2024年赣州旅游投资集团社会招聘笔试真
2024年赣州旅游投资集团社会招聘笔试真 题 ( 满 分 1 0 0 分 时 间 1 2 0 分 钟 ) 一、单选题(每题只有一个正确答案,答错、不答或多答均不得分) 1.纪要的特点不包括()。 A.概括重点 B.指导传达 C. 客观纪实 D.有言必录 【答案】: D 2.1864年,()预言了电磁波的存在,并指出…...
linux 错误码总结
1,错误码的概念与作用 在Linux系统中,错误码是系统调用或库函数在执行失败时返回的特定数值,用于指示具体的错误类型。这些错误码通过全局变量errno来存储和传递,errno由操作系统维护,保存最近一次发生的错误信息。值得注意的是,errno的值在每次系统调用或函数调用失败时…...
SpringTask-03.入门案例
一.入门案例 启动类: package com.sky;import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCach…...
【碎碎念】宝可梦 Mesh GO : 基于MESH网络的口袋妖怪 宝可梦GO游戏自组网系统
目录 游戏说明《宝可梦 Mesh GO》 —— 局域宝可梦探索Pokmon GO 类游戏核心理念应用场景Mesh 特性 宝可梦玩法融合设计游戏构想要素1. 地图探索(基于物理空间 广播范围)2. 野生宝可梦生成与广播3. 对战系统4. 道具与通信5. 延伸玩法 安全性设计 技术选…...
Ubuntu Cursor升级成v1.0
0. 当前版本低 使用当前 Cursor v0.50时 GitHub Copilot Chat 打不开,快捷键也不好用,当看到 Cursor 升级后,还是蛮高兴的 1. 下载 Cursor 下载地址:https://www.cursor.com/cn/downloads 点击下载 Linux (x64) ,…...
LangFlow技术架构分析
🔧 LangFlow 的可视化技术栈 前端节点编辑器 底层框架:基于 (一个现代化的 React 节点绘图库) 功能: 拖拽式构建 LangGraph 状态机 实时连线定义节点依赖关系 可视化调试循环和分支逻辑 与 LangGraph 的深…...
