ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

交换链重建(Swap Chain Recreation)完全指南:D3D12窗口调整不再花屏

交换链重建(Swap Chain Recreation)完全指南:D3D12窗口调整不再花屏 我最初写那个画三角形的 Demo 时以为最难的部分是把渲染管线搭起来编译着色器、创建顶点缓冲、设置根签名这一套做完屏幕上总算出现那个彩色的三角形。结果没高兴多久一拖窗口边框画面直接花掉甚至有时候黑屏、报错退出。排查了半天才发现问题根本不在管线而是交换链Swap Chain——窗口尺寸变了交换链和后备缓冲的关系还停留在旧状态。而处理这件事的正确姿势就是标题里那个词swap chain recreation交换链重建。这篇文章不打算从什么是交换链的教科书定义讲起而是按我实际调这个 Demo 的路径来走一遍为什么非重建不可、重建到底要重做哪些事、代码上怎么安排最稳以及我在这个过程中踩过的坑。Demo 用的是 D3D12DirectX 12但里面的思路放到 Vulkan 上一样成立你把对应的对象名换一换就行。适合正在写第一个 D3D12 三角形程序、或者已经画出来了但一调整窗口就出问题的朋友参考。1. 为什么交换链不能原地调整而要整个重建1.1 交换链和窗口尺寸的绑定关系先回忆一下交换链是干嘛的它本质上管理着一组后备缓冲这些缓冲把你渲染的结果呈现到窗口上。创建交换链时DXGI_SWAP_CHAIN_DESC1里有两个字段非常关键Width和Height。这两个值决定了后备缓冲的分辨率而它必须和窗口客户区尺寸匹配否则显示就会出问题。这里要理解一个底层事实后备缓冲的格式、尺寸、数量在交换链创建时就被固定下来。窗口拖动之后客户区尺寸变了但交换链内部那几张后备缓冲还保持旧尺寸。这时候如果你继续渲染结果就是把一张固定分辨率的图像贴到一个尺寸不同的窗口上。轻微情况是画面被拉伸变形严重情况是呈现逻辑直接出错黑屏、花屏、甚至设备丢失都见过。有人可能想那我每次渲染前重新创建后备缓冲行不行不行因为后备缓冲是交换链管理的资源你没法绕开交换链单独重建它们。唯一干净的路径就是让交换链本身知道尺寸变了重新按当前窗口尺寸生成一份匹配的后备缓冲。这就是 recreation 存在的意义。1.2 哪些变化会触发重建窗口尺寸变化是最常见的触发条件但我排查过程中发现触发点远不止这一个。结合我 Demo 的实际情况和网上各种 issue 的反馈至少下面几种情况需要考虑窗口客户区尺寸变化拖动窗口边框、最大化、恢复这是最典型的重建时机。全屏与窗口模式切换从窗口模式切到全屏或者反过来交换链必须重建才能应用新的呈现模式。显示器分辨率或刷新率变化比如你从 60Hz 的外接屏拔线回到 144Hz 的内屏哪怕窗口没动交换链也可能需要重建。显卡驱动更新或者设备状态变更这种情况比较少见但一旦出现DXGI_ERROR_DEVICE_REMOVED基本只能销毁重来。垂直同步模式或帧延迟调整如果你要动态切换是否开启 VSync有些实现也要求重建。我一开始只做了第一种后来拖动窗口时频繁出现闪烁才意识到后几种情况如果不处理会在某些特定操作序列下直接让程序崩掉。所以设计时建议不要只监听 WM_SIZE把 WM_DISPLAYCHANGE、WM_DPICHANGED 这些系统消息也一并纳入重建触发条件。2. 重建流程的完整拆解搞明白为什么之后就看具体怎么做。表面看重建就是销毁 重新创建但这里面资源依赖关系比想象中复杂稍不留神就会在关键时刻引用到已经释放的东西。2.1 第一步等待 GPU 用完当前帧这是最容易被新手跳过的部分。你的渲染循环可能是这样的逻辑提交命令列表 → Present → 进入下一帧。表面看上一帧已经结束了但 GPU 是异步执行的你 CPU 端的命令可能还在 GPU 的队列里排队那些命令引用的后备缓冲、描述符资源都还在使用中。如果在 GPU 还没用完后备缓冲时就释放它轻则警告重则直接设备挂掉。标准做法是让 CPU 等着 GPU 走到一个安全点。我用的方式是经典的围栏Fence同步每一帧结束时往命令队列里插一个围栏信号记录当前的围栏值重建前取出当前已提交的围栏值再去等一个比它更高的值被信号触发。简化后的伪代码逻辑如下D3D12 风格// 记录当前命令队列已经提交到的围栏值 UINT64 lastCompletedValue fence-GetLastCompletedValues(); UINT64 currentFenceValue fenceValue; // 如果队列里还有正在执行的任务让 CPU 阻塞等待 if (fence-GetCompletedValue() currentFenceValue) { HANDLE eventHandle CreateEvent(nullptr, FALSE, FALSE, nullptr); fence-SetEventOnCompletion(currentFenceValue, eventHandle); WaitForSingleObject(eventHandle, INFINITE); CloseHandle(eventHandle); }这段逻辑的本质是把所有可能引用旧交换链的 GPU 工作全部执行完再动交换链。顺序反过来的话新交换链创建了但旧交换链的资源还没真正释放两个交换链同时在驱动层存在某些驱动实现会给你虚空报错排查半天都不知道问题出在哪。2.2 第二步释放旧交换链相关的所有引用等 GPU 空闲后就可以释放旧交换链了。但这里有个隐蔽点你不能只释放交换链对象本身。我第一版代码就是只调了 IDXGISwapChain 的 Release结果下一次 Present 直接崩溃——原因在于原来从后备缓冲创建出来的渲染目标视图RTV、深度模板视图DSV以及可能创建出来的着色器资源视图SRV这些描述符对应的堆内存都还挂着旧资源的引用。更麻烦的是交换链在ResizeBuffers被调用后旧后备缓冲可能已经失效但你的描述符堆里还存着指向旧资源的视图。如果不一并处理轻则画面显示异常重则驱动层面出现资源泄漏。我习惯的做法是做一个专门的重建函数里面把这些东西全部收敛起来void RecreateSwapChain() { // 等待 GPU 完成所有作业 WaitForGPU(); // 释放与后备缓冲直接关联的视图资源 // 这一步要看你的资源组织方式SAFE_RELEASE(rtvHeap 相关)、或者重置描述符堆区间 for (int i 0; i bufferCount; i) { renderTargetViews[i]-Release(); } // 深度缓冲同理 SAFE_RELEASE(depthStencilBuffer); SAFE_RELEASE(depthStencilView); // 关键调用 ResizeBuffers 而不是重新 CreateSwapChain HRESULT hr swapChain-ResizeBuffers( bufferCount, // 缓冲数量尽量保持不变 newWidth, // 新的客户区宽 newHeight, // 新的客户区高 DXGI_FORMAT_R8G8B8A8_UNORM, // 格式保持一致除非有特殊需求 DXGI_SWAP_CHAIN_FLAG_ALLOW_TEARING ); // 重建后备缓冲的视图 for (UINT i 0; i bufferCount; i) { swapChain-GetBuffer(i, IID_PPV_ARGS(backBuffer[i])); device-CreateRenderTargetView(backBuffer[i], nullptr, rtvHeap-GetCPUDescriptorHandleForHeapStart(i)); } // 重建深度缓冲和视口 }这里我特意用了ResizeBuffers而不是销毁后重新创建IDXGISwapChain原因后面专门说。2.3 第三步重新创建后备缓冲的视图旧引用清干净之后用GetBuffer方法从新交换链里取新的后备缓冲。注意这里返回的可能是同一批缓冲也可能是换了一批取决于驱动内部的实现。无论哪种情况你都不能假设缓冲没变就复用旧 RTV因为连管理这些缓冲的元数据可能都变了。重新创建 RTV 的代价很低本质上就是往描述符堆里写几行描述符所以不要省这个功夫。同样地窗口尺寸变化后深度缓冲的尺寸也需要跟着变视口Viewport和裁剪矩形Scissor Rect也要用新的宽高重新设置。这些细节不做完到显示那一步就会出各种诡异问题三角形只占屏幕一角、画面裁剪错位、深度测试结果不对。视口和裁剪矩形的设置比较机械但特别容易忘。我的经验是把它们也放进重建函数里跟后备缓冲视图一起重建避免只改了一半的混沌状态。3. 交换链重建的两种实现路径对比3.1 路径 A销毁后重新 CreateSwapChain很多人包括我一开始的第一直觉都是把旧交换链释放掉再调用一次CreateSwapChain。这条路能不能走通能但有明显的坑。最大的问题是窗口句柄还没变但你用同一个 HWND 再次创建交换链时驱动可能给你返回一套全新的后备缓冲旧的那套还没完全被清理干净。如果你没有把旧对象的所有引用彻底释放就会出现旧交换链还在后台占用资源新交换链同时存在的情况。在某些驱动组合下这会导致创建失败或者更糟创建成功但表现行为异常。另一个问题是效率创建交换链并不便宜它要跟窗口系统打交道申请后备缓冲还可能触发驱动重编译相关的路径。如果只是窗口尺寸变化完全没必要付出这个代价。释放加重建这套操作本身也有更多出错点比如你重建了交换链但忘了把顶点缓冲、管线状态对象等其他资源重新绑定渲染就断在半路。3.2 路径 B使用 ResizeBuffers 原地重建微软的 DXGI 设计里ResizeBuffers本来就是给窗口尺寸变了这个场景用的。它的实现逻辑是在交换链内部重新分配后备缓冲并尽量保留交换链对象本身。这样做有几个显而易见的好处对象生命周期更清晰不会出现新旧两个交换链并存的混乱。大部分底层资源可以复用创建开销更低。设置过的一些参数比如关联的窗口句柄、呈现模型不用重新配一遍。Vulkan 里对应的概念是vkCreateSwapchainKHR可以在旧交换链基础上重建并把旧交换链传进去做优化提示思路完全一致。所以我的建议很明确窗口尺寸变化优先用 ResizeBuffers而不是销毁重建。销毁重建只在真正需要彻底重置的状态下用比如设备丢失、显卡热切换这种大动干戈的场景。4. 关键细节Present 的返回值和重建触发时机重建流程本身不算复杂真正让这个功能难调试的是它跟渲染循环的配合。尤其是Present的返回值这算是踩坑最多的地方之一。4.1 DXGI_ERROR_WAS_STILL_DRAWING 的陷阱如果你开了垂直同步VSync帧率被限制为显示器的刷新率。当你拖窗口时渲染循环可能因为重建动作停顿了一下这时候下一次Present有可能会返回DXGI_ERROR_WAS_STILL_DRAWING。这个错误码的意思是上一次呈现还没完成GPU 还没准备好接受新的一帧。很多代码对Present的成功判断是if (FAILED(hr))这样一旦把DXGI_ERROR_WAS_STILL_DRAWING当成失败处理就会走重建交换链的路径然后整个程序陷入一种明明没坏却被反复重建的状态。我一开始就把这个错误码当成致命错误结果窗口一拖程序就进入无限重建循环CPU 占用直接拉满。正确的做法是这个错误码可以被忽略就当这次滑动没发生下一帧再尝试。但如果你发现它持续出现说明你的帧节奏有问题需要回头看是不是命令队列里积压了太多帧没有同步。4.2 DXGI_ERROR_DEVICE_REMOVED 该怎么办DXGI_ERROR_DEVICE_REMOVED是真的致命错误大概率意味着显卡驱动重置或者设备被拔出。这个情况下你要做的不是单纯重建交换链而是要调用IDXGIDevice::GetDeviceRemovedReason()看具体原因。释放与设备相关的所有资源包括命令队列、命令分配器、管道状态对象、根签名等。重新创建设备和整个渲染管线。最后重建交换链。这一步做完基本等同于把整个程序重启一遍只是不用退出进程。这个流程我在 Demo 里没有完整实现只做了日志记录因为完整的设备重置涉及的东西太多跟交换链本身关系不大。大家至少要认得这个错误码不要把它当成交换链重建能解决的问题。4.3 WM_SIZE 里拿到的尺寸可能不是你要的窗口尺寸变化的通知通常走WM_SIZE消息但lParam里给的是窗口的宽高不是客户区的宽高。客户区才是交换链后备缓冲需要匹配的区域。这两个值在带标题栏、菜单栏、边框的窗口下图谋不轨可能差几十个像素。如果直接用LOWORD(lParam)和HIWORD(lParam)去重建画面只会被轻微拉伸但你觉得没问题直到你切到全屏或者关了边框画面又开始错位。正确姿势是用GetClientRect拿客户区尺寸。另外要注意一种情况窗口被最小化时客户区尺寸可能变成 0。如果你用 0 去调用ResizeBuffers部分实现会直接报错或者更恶劣——返回一个你根本没法用的交换链。我所以见到的稳健写法是检测到宽或高为 0 时什么都不做直接跳过这一帧的重建等窗口恢复后再重建。一个常见的值陷进是WM_SIZE里的SIZE_MINIMIZED标志或者高为 0 时把 width 也强制设成 1。最优解是直接 return等恢复后再处理。5. 实操过程中的其他坑与避坑清单5.1 描述符堆被覆盖的问题我用的 RTV 描述符堆在创建时只预留了bufferCount个描述符。但窗口尺寸变化后如果我不小心在新缓冲创建前就写描述符之前那个堆上已经有旧缓冲的 RTV那新写入就可能覆盖旧描述符。这个行为在 CPU 端看起来没问题但 GPU 端如果还在用旧描述符就会出现画面撕裂。解决做法是严格按等待 GPU 完成 → 释放相关资源 → 重建的顺序执行不要让新描述符的写入和旧描述符的使用产生重叠。如果调试时发现画面撕裂但程序不崩先怀疑是不是这个时序问题。5.2 缓冲数量到底该设多少ResizeBuffers的第一个参数是缓冲数量。我最初设的是 2双缓冲后来改成 3三缓冲。这里说下我的实际感受双缓冲在开启垂直同步时帧率会因为 GPU 跟不上刷新率而减半三缓冲能缓解这个问题代价是每帧延迟增加一点点。但对于画三角形的 Demo 来说帧生成速度远快于刷新率双缓冲就够了。三缓冲在某些显卡驱动上反而会让 Present 的返回时机变得更加不可预测。我的建议是没有特殊需求就保持双缓冲能省点显存调试时也少一层变量。如果你想在程序里支持动态切换是否开垂直同步那需要考虑一下缓冲数量是否要随之变化——部分驱动在切换 VSync 时需要重建交换链这又是一个触发 recreation 的场景。5.3 深度缓冲不能偷懒窗口尺寸一变深度缓冲不重建渲染结果就会出现很隐蔽的问题。三角形可能看起来一切正常但一旦进行深度测试就会出现莫名的遮挡关系错误或者局部像素闪烁。我遇到过最迷惑的问题背景清屏没用深度值清导致第一次渲染的三角形被第二次的三角形完全覆盖看起来像画面闪烁。这个问题的根源就是深度缓冲尺寸和后备缓冲不匹配深度测试结果完全错乱。重建流程里一定要显式释放旧的深度资源按新尺寸重新创建深度纹理和深度视图然后重新绑定到渲染目标上。5.4 全屏切换时特别留意 ALTENTER很多人会用ALTENTER做全屏切换这个操作如果没走交换链重建画面会直接花掉。Windows 为了兼容性在 ALTENTER 时会自动处理一部分交换链逻辑但控制权其实很含糊不同驱动表现不一致。最稳妥的方案是自己监听 ALTENTER手动调用重建流程把全屏状态也作为重建的一个状态变量管理起来。这样 Windows 的自动逻辑和你的手动逻辑不会打架。我遇到过一个情况在窗口模式下按 ALTENTER驱动自动切全屏程序侧完全没有感知交换链还是窗口模式的交换链结果整个画面黑掉只有鼠标能看到。后来我禁止了系统的 ALTENTER 自动处理全部改由自己程序逻辑控制这个问题才消失。6. 一个完整的重建流程速查表写代码时我总结了一张速查表每次改重建逻辑都对着看挺管用贴出来供参考步骤动作注意点1关闭或跳过当前帧渲染不要让新命令引用旧资源2等待 GPU 完成所有已提交作业用 Fence 阻塞别用 Sleep 硬等3释放与旧后备缓冲相关的视图RTV、DSV以及任何 SRV 引用4释放深度缓冲资源尺寸不匹配是隐形炸弹5计算新客户区宽高用 GetClientRect最小化时跳过6调用 ResizeBuffers参数保持格式和缓冲数量一致7获取新后备缓冲并重建成 RTVGetBuffer CreateRenderTargetView8重建深度缓冲和深度视图按新尺寸创建9更新视口和裁剪矩形全屏切换时尤其注意10恢复渲染循环重新提交命令即可这个顺序不能乱尤其是 2 和 3你必须在确认 GPU 完全不再引用旧资源后才能释放视图否则理论上会出现悬空引用。实践中一些驱动容忍这个问题但代价是间歇性的画面撕裂和闪黑排查成本极高。先把顺序固定下来再去想优化。另外整个重建过程中如果出现任何HRESULT错误最好用DXGI_ERROR_...那一串错误码对照一下再决定下一步。不要条件反射地认为所有失败都能靠多试几次解决比如设备丢失类错误重试不但没效果还可能让驱动状态更加混乱。7. 我最终的 Demo 状态和一点体会把交换链重建理顺之后我的三角形 Demo 终于能正常拖拽窗口了任意拉伸、最大化、从全屏切回窗口三角形始终铺满窗口颜色和位置都正确。这个过程中最大的收获不是学会了ResizeBuffers这几个 API而是彻底理解了后备缓冲的生命周期这个问题。我之前总把交换链当成一个永久的上下文来用觉得创建了就完事。实际上它更像是你跟窗口系统之间的一纸合同合同内容里包括尺寸、格式、呈现模型。窗口系统那边变了你这边不更新合同画面自然要出问题。理解了这一点再去读 DXGI 的文档很多此前觉得含糊的 API 设计意图就顺了。按我个人经验任何图形程序只要有窗口迟早都会遇到交换链重建的需求。与其到时候用销毁全部再重建这种粗笨方案去应付不如一开始就把重建单独写成一个模块把同步、资源释放、视图重建的时序固定下来。后面不管是加多窗口、切全屏还是支持显示器热插拔都只需要在那个模块里补逻辑不用把渲染主循环搅得一团糟。调试这种问题还有一个很值当的工具在关键路径上打日志特别是把ResizeBuffers的参数、返回值、触发原因全部记录下来。我遇到过几次看起来像是驱动 bug的情况翻日志发现其实是自己代码传了 0 尺寸或者描述符堆越界。日志多打一行能省掉很多深夜瞎猜的时间。
返回列表