D3D12之Sync Point

说到Sync point,这块的知识点比较杂,但是对于渲染引擎来说又特别重要,因此想写一篇文章把这两天学到的知识点总结一下,要不然时间久了肯定就忘记了。

先从视觉暂留说起,我们的眼睛在看到影像以后,会将影像暂留一段时间,因为眼睛的这个特性,我们就可以把连续的影像变为离散的,只要影像切换的足够快,给人的感觉就是连续的,这就是电影的原理。

显示器,手机屏幕,都有一个叫刷新率的参数,比如每秒60Hz,每秒144Hz。这个刷新率就代表屏幕一秒可以刷新多少次,也就是屏幕一秒可以切换多少帧。

屏幕帧的切换并不像PPT切换那样,所有的像素同时切换,而是通过扫描点逐行扫描屏幕上的像素,然后一行一行的扫描,最终将整个屏幕扫描完毕,扫描的算法有很多,这里不是重点,重点是屏幕的刷新并不是瞬时的,而是具有持续过程的,当扫描点结束一帧扫描后,会重置到起点,我们把这个重置扫描点的过程称为VBlank

上面提到了的概念,游戏中帧代表一次游戏循环,因为渲染画面是游戏的最终输出结果,因此可以说帧就是显卡渲染了一次画面,而帧数(FPS)就是显卡一秒钟能够渲染多少画面

一帧画面其实就是显存中的一组数据,显卡负责写数据,屏幕负责读数据,这段显存我们称之为帧缓存。因为画面需要一个完整帧一个完整帧的更新,因此显卡的写和屏幕的读是串行的,屏幕显示一张画面需要经历完整的显卡渲染+屏幕扫描时间,这是一种非常低效的做法,为了提高渲染效率,我们需要通过空间换时间。

我们可以申请两个帧缓存,一个叫做Front Buffer,它负责给显示器读,另一个叫做Back Buffer,它负责给显卡写,当显卡写完之后我们就可以交换这两个缓存,重复上述的过程,这个技术叫做Buffer Swap

windows操作系统有两种Buffer Swap技术,分别叫做BitBlt model以及Flip model,早期的windows系统使用的是BitBlt model,DX12只支持Flip model。这两种模式最主要的区别就是BitBlt model需要把Back Buffer拷贝到DWM redirection surface,而Flip model不需要拷贝,因为DWM共享了Back buffer,因此DWM可以直接使用Back buffer。总之Flip model可以加快Swap的速度,而且DX12只支持Flip model,因此我们在创建IDXGISwapChain的时候需要填写swapChainDesc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD Or DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL使用Flip model的buffer不支持multisampling,换句话说DX12不支持multisampling的swap chain(swapChainDesc.SampleDesc.Count = 1),如果想要使用multisampling,我们需要将画面渲染到支持multisampling的Render Texture,然后再通过 ID3D12GraphicsCommandList::ResolveSubresource 将中间纹理转换到no-multisampling的swap chain中。

因为每一帧的场景复杂度不同,因此显卡绘图的速度是不固定的,而屏幕的扫描速度是固定的,如果使用Buffer Swap技术就会照成撕裂现象。无论FPS的值大于还是小于刷新率,都会照成撕裂。因为它在扫描的过程中切换了画幅,从而造成了撕裂。

为了解决撕裂的问题,引入了垂直同步技术,这个技术的核心点就是,显卡绘图结束后不能随便进行Swap,必须要等到下一次的VBlank才能进行Swap,这样就避免了在扫描的过程中切换画幅,从而也避免了撕裂的问题。

这里引入一个细节问题,DX12如何通知显卡我完成了一帧操作,看下面的代码:

void D3D12Sample::OnRender()
{
    // Execute the rendering work.
    ID3D12CommandList* ppCommandLists[] = { m_commandList.Get() };
    m_commandQueue->ExecuteCommandLists(_countof(ppCommandLists), ppCommandLists);

    // Present the frame.
    ThrowIfFailed(m_swapChain->Present(1, 0));
}

首先ExecuteCommandLists会将所有绘制的指令提交给GPU,如果没有wait的话GPU就会开始处理这些指令,ExecuteCommandLists是一个非阻塞函数,也就是说对于cpu而言它不会阻塞调用该函数的线程。而且ExecuteCommandLists函数会保证gpu处理的完整性,简单理解就是调用的指令会全部执行完毕,包括刷新缓存等等一系列显卡内部的操作。

Present函数其实是在Command queue后面添加一个提交指令(在创建Swap chain的时候需要传入一个command queue)这样当cpu处理完全部绘制指令后,gpu会执行present指令,从而会发生swap buffer操作。但是Present是一个阻塞函数,它会在达到一定条件下阻塞当前线程,这个条件我稍后再讲,这里只是理解我们如何通过代码控制显卡提交画面。

Present函数的第一个参数为1,第二个参数为0,简单理解就是开启了传统的垂直同步,也就是需要等到VBlank的时候才能进行Swap操作。

垂直同步技术有三个好处:

  1. 可以解决撕裂问题
  2. 对于FPS大于刷新率的情况,可以减少功耗(省电),因为GPU在等待提交的时候是处于休眠状态的。(这一点即是优点也是缺点,看不同游戏类型吧)
  3. 如果FPS大于刷新率的话,可以从一定程度上平滑帧,因为此时的帧切换是依靠屏幕刷新率决定的,屏幕刷新耗时是固定的,所以帧切换会很平滑。

垂直同步技术有两个坏处:

  1. 对于FPS小于刷新率的情况,性能可能会更差,因为如果GPU错过一次VBlank,那么它只能在下一次VBlank的时候提交,这就相当于上一帧画面被画了两次,FPS被迫降到了之前的一半。
  2. 画面的延迟,因为画面的刷新需要等待VBlank,所以画面会有一定的滞后。
  3. 输入的延迟,这个要看程序是怎么设计的,如果逻辑和渲染分离,那么输入的延迟就和渲染没有关系了。对于实时性要求比较高的游戏,就需要通过逻辑和渲染分离来降低输入延迟。所以说因为垂直同步导致输入延迟这种说法并不严谨。

Adaptive V-Sync(自适应垂直同步)

为了解决垂直同步在低帧率情况下引起的性能问题,NVIDIA提出了自适应垂直同步技术,也就是当帧率大于刷新率的时候开启垂直同步,当帧率小于刷新率的时候关闭垂直同步,这算是一种折中的解决方案吧。

Triple Buffer(三重缓存)

网络上包括科普视频里面讲到三重缓存是为了解决延迟的问题,这是非常错误的,对于游戏开发者请不要被误导。

首先说输入延迟,上面我已经说了,如果要求输入灵敏度比较高,那么请使用逻辑与渲染分离技术,而不是使用三重缓存来解决。

如果说是画面延迟(画面被显卡绘制完成到被显示器显示之间的延迟),那么使用垂直同步技术就会产生至少1VBlank的延迟,如果使用三重缓存并且不丢弃较老的中间帧,那么三重缓存的延迟就是2VBlank,延迟反而更高了。

另外不开启垂直同步技术就不要谈三重缓存了,因为二重缓存已经可以让GPU跑满了。

Fast V-Sync(快速垂直同步)

在开启三重缓存的基础上,只要后缓存画好后,中缓存就与后缓存交换,这样GPU可以不被阻塞一直工作下去,我个人觉得这个技术只是应对个别场景,并不是一个很好的解决方案:

  1. GPU一直工作会加大功耗
  2. 会照成画面的不连续,因为有很多中间帧被直接丢弃了

说了这么多感觉三缓存技术好像很鸡肋,那为什么现在大部分游戏都使用三缓存技术呢?我来列举一个另外的场景,帧同步技术。

帧同步技术中服务器会将一帧的指令发送给客户端,然后客户端模拟这帧的指令。客户端是按照一定的速度进行Update,如果Update的速度快于网络延迟的速度,那么这个时候客户端会因为没有新的指令而被迫静止在上一帧。网络速度是不稳定的,为了对抗网络延迟,客户端会有一个命令队列,这个队列会存储多个帧的指令,这样当网络好的时候,队列可以存储多个帧的指令,当网络不好的时候,因为队列中存储多个帧的指令,客户端不会出现被迫静止的情况,这个队列的存在就是为了对抗网络波动。队列存储命令越多,对抗网络波动的能力越强,但是与服务器之间的差距会越大。为了处理这个差距,客户端有的时候会加速Update来追赶服务器。

同理,三缓存技术其实也是为了对抗FPS的波动,因为场景复杂度不同,帧率可能忽高忽低,这样使用多个缓存就可以对抗这种波动。但是显示器和客户端不一样,客户端可以调节Update的速度,显示器的扫描速度确是固定的。因此要么丢掉中间帧,降低延迟(照成画面不连续),要么一个缓存一个缓存的更新,这里鱼和熊掌不能兼得。

G-Sync Free-Sync

垂直同步是显卡等待显示器

VRR是显示器等待显卡

我们知道VBlank的时间是固定的,所以当我们开启垂直同步的时候,显卡会等待VBlank的时候再进行Swap。

如果显示器可以等一下显卡,等到显卡画完一帧后再进行扫描,那么就可以解决显卡慢一点帧率就降到原来一半的问题。也就是说VRR中的VBlank‘是可变的,它应该是VBlank' >= VBlank。

因为屏幕的扫描速度不变,VBlank'只能大于等于VBlank,不能小于VBlank,所以VRR技术也不是银弹。

VRR技术只能解决FPS略微低于刷新率的情况。当两者相差很大的时候,还是会有问题。

比如FPS>刷新率,如果不开垂直同步,还是会导致撕裂问题。

如果FPS大幅度小于刷新率,因为屏幕的刷新率是有下限的,如果长时间不更新显示屏,可能会出现花屏等显示器硬件问题(液晶显示器保存画面的时间有限)。因此我们需要动态调整刷新率,这里就出现预测问题了,这里就不继续扩展了。说到这里我想说技术很多时候都是相通的,帧同步的网络延迟问题和这里说的显卡和显示器同步的问题非常相似。

说了这么多其实我一直在说GPU与显示器之间的Sync Point,最后做一个总结。

同步方案画面撕裂画面丢帧延迟时间
双缓存YesNo0
双缓存+垂直同步

No

No0~1VBlank
三缓存+垂直同步NoNo0~2VBlank
三缓存+快速垂直同步NoYes0~1VBlank
双缓存+G-Sync(FPS < 刷新率)NoNo0~1FrameTime
双缓存+G-Sync(FPS > 刷新率)YesNo0~1VBlank

接下来我们聊一下CPU与GPU的Sync Point。

对于游戏来说,每一个游戏都会有一个目标FPS比如每秒60帧,换算成毫秒就是一帧消耗16.7ms。

一帧代表更新一次画面,而一帧的画面是由cpu和gpu共同完成的。cpu是指挥者它告诉gpu画什么,怎么画,gpu是执行者,执行cpu的命令。如果cpu和gpu是串行工作,那么一帧消耗的时间就是:

FrameTime = CPUTime + GPUTime <= 16.7ms

串行工作是低效的,通常我们希望GPU和CPU并行工作,如果是并行工作那么一帧的消耗时间是:

FrameTime = Max(CPUTime,GPUTime) <= 16.7ms

可以看到要想达到每秒60帧,cpu和gpu的消耗时间都要小于16.7ms。

  1. 如果CPUTime > 16.7ms && GPUTime < 16.7ms,那么GPU就会出现空闲状态,等待CPU。
  2. 如果CPUTime < 16.7ms && GPUTime > 16.7ms,那么GPU就会一直跑满,但是GPU已经达到极限,它怎么跑都跑不到60帧。
  3. 如果CPUTime > 16.7ms && GPUTime > 16.7ms,哥俩好可以休息睡觉了。
  4. CPUTime < 16.7ms && GPUTime < 16.7ms,达到目标了,不过我们还要更多的效果,吐血。

FrameCount是根据FrameResource来定义的,每一帧都有一些GPU资源需要CPU来更新,如果GPU还没有处理完这些资源就被CPU更新了,那么就会造成未定义的错误,因此我们会定义多个FrameResource,这些资源是被某一帧独占的,在D3D12中我们需要通过围栏来确保这些资源被GPU处理完毕后,才能继续使用这一帧,FrameCount就是这些Render Frame的个数,要想CPU和GPU并行工作必须FrameCount >= 2。

Maximum Frame Latency即预渲染队列最多等待帧的个数,可以理解为Command Queue中最多可以保存多少个Present指令Maximum Frame Latency值越大代表一帧画面从生成到提交到显示器的延迟越大。默认情况下Maximum Frame Latency=3,这里体现在Present函数的阻塞(之前提到过),如果快速提交3次Present,而Present还没有被Swap,那么Present函数会在第四次提交的时候阻塞,直到有一个Present被swap后被唤醒。如果想自己控制预渲染队列的个数,则需要配合Waitable Object使用,如果使用Waitable Object,那么Present就不会阻塞函数,我们需要自己通过WaitForSingleObjectEx函数来判断当前预渲染队列是否还有位置,我们可以通过 IDXGISwapChain2::SetMaximumFrameLatency函数来设置队列的长度,我们可以将Maximum Frame Latency设置为1,这样就可以实现最小的延迟Waitable Object和Fence功能很类似,只不过Fence更灵活,因为它可以放到Command Queue的任何位置,而Waitable Object只针对Present指令。DX12下应该可以不使用Waitable Object,直接使用Fence,除非你想打破Present的默认阻塞行为。

BufferCount即帧缓存的个数,为了完成Swap,必须BufferCount>=2,如果BufferCount = 2,那么同一时刻只有一个BackBuffer可以被GPU写,让我看一下FrameCount和BufferCount的关系,假设FrameCount=2,BufferCount=2,Maximum Frame Latency = 3。

  • FrameTime1,FrameTime2 <= 16.7ms,此时Frame1先写入BackBuffer,然后等到VBlank swap,然后Frame2再写入BackBuffer,然后等到VBlank swap,这种情况没有任何问题。
  • 33.4 > FrameTime1 > 16.7ms,FrameTime2 < 16.7ms,Frame1错过了VBlank,它现在正在等待下一次的VBlank,因为BackBuffer中存储的是Frame1的内容,它还没有被提交,所以GPU必须阻塞,等到Frame1提交后再进行绘制,这种情况就是出现了帧波动,为了对抗帧波动,我们需要使用多重缓存。
  • 令FrameCount=2,BufferCount=3,Maximum Frame Latency = 3。
  • 33.4 > FrameTime1 > 16.7ms,FrameTime2 < 16.7ms,FrameTime3 < 16.7ms,FrameTime4 < 16.7ms,Frame1错过了VBlank,它现在正在等待下一次的VBlank,Frame1目前占用BackBuffer1,此时Frame2可以在BackBuffer2上进行绘画,等到Frame3的时候,BackBuffer1可以使用了,虽然在Frame1的时候由于错过了VBlank导致帧率下降一半,但是如果没有使用多重缓存,显示器还要多等一次VBlank,因为此时GPU正在绘制Frame2。 
  • 对于FrameCount = BufferCount - 1的情况,每一帧Frame都会匹配一个BackBuffer,因此不会出现因为竞争BackBuffer而导致的延迟现象。

Intel给出了三个最佳的组合,如下图:

具体使用哪个模型可以根据实际的游戏需求进行选择。

之前我们谈到CPU和GPU的Sync Point都是CPU阻塞,然后等待GPU唤醒,DX12也可以让GPU阻塞然后等待CPU唤醒。

ID3D12CommandQueue::Wait函数可以让GPU等待一个Fence,然后通过ID3D12Fence::Signal来唤醒GPU。

最后说一下GPU内部之间的Sysn Point

D3D12有三个引擎Copy,Graphic,Compute,这三个引擎可以异步调用,从而提高GPU的使用率。

Copy引擎

目前我使用Copy引擎主要是用于将上传堆中的资源Copy到默认堆,为了减少Sysn point,这些资源的Copy行为不会阻塞GPU的其他引擎,比如细节贴图,这些贴图可以延迟几帧再使用。我会在每一个资源Copy加上一个Fence,然后在某个时间点查看这个Fence是否已经被处理,从而确定资源是否已经Copy完成。

Graphic和Compute之间的Sysn Point

目前我设计的引擎是,每一个Frame会被拆分成多个子模块,每个子模块分配一个线程,分配一个CommandList。如果模块和模块之间有依赖关系,那么就需要进行同步,同步的策略(支持跨引擎同步):

  1. 被依赖的模块engine->Signal(fence,X)
  2. 依赖的模块engine->Wait(fence,X)

CPU之间的Sysn Point这个是我们最常用的,这就不说了,具体问题具体分析。

垂直同步工作原理的科普视频:https://www.bilibili.com/video/av883829490/

Flip model:https://docs.microsoft.com/zh-cn/windows/win32/direct3ddxgi/dxgi-flip-model?redirectedfrom=MSDN

DXGI_SWAP_EFFECT:https://docs.microsoft.com/en-us/windows/win32/api/dxgi/ne-dxgi-dxgi_swap_effect

”Reduce Latency with DXGI 1.3 Swap Chains.” https://msdn.microsoft.com/en-us/library/windows/apps/dn448914.aspx

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值