最早我在一个 D3D11 小项目里画三角形,画面出来还没高兴多久,随手把窗口拖了两下,直接黑了。再点一下,白屏,再拖,崩了。查了一天,着色器没问题、顶点数据没问题,最后才发现是 Swap Chain 没有做 Recreation——交换链重建。画三角形是图形开发里最“人畜无害”的一步,但很多人恰恰是卡在这一步后面的 Swap Chain 重建上:窗口大小一改,后台缓冲尺寸没跟上,整个渲染链就断了。
这篇文章不打算只贴一段“Resize 代码”糊弄过去。我会把 Swap Chain 到底在干什么、为什么窗口一变就不能用了、重建的标准流程是什么、最容易在哪些细节上翻车,全部拆开讲清楚。无论你用 D3D11、D3D12、Vulkan 还是 WebGPU,底层思路是一样的,代码骨架可以直接抄。
1. 先从“为什么要重建”说起
1.1 一个 Swap Chain 到底是什么
很多图形入门教程会把“画三角形”解释成:准备好顶点,写个像素着色器,调用 Draw 就完事。但实际画出来的东西并不会直接飞到屏幕上,它先被渲染到一个叫 Back Buffer(后台缓冲)的纹理上,经过一次 Present(呈现),这个后台缓冲的内容才会显示到窗口里。
Swap Chain 就是一种“管理这些后台缓冲”的机制。它通常维护两个或三个缓冲,一次呈现结束后,两个缓冲进行交换,一个被送去显示器扫描,一个继续给 GPU 绘制。屏幕撕裂、垂直同步、帧率控制这些概念,本质上都和 Swap Chain 的交换策略绑定。
用生活里的方式理解,Swap Chain 就像餐厅的传菜口。厨师(GPU)把菜(渲染结果)放到传菜口一格的托盘上,服务员(显示器驱动)取走送去餐桌。如果传菜口托盘的大小和要放的菜尺寸不一致,这菜是放不上去的,或者说就算硬塞上去,端到顾客面前也是形状不对。
所以,Swap Chain 在创建初期,会按照窗口当前客户区的大小,为每个后台缓冲分配对应的纹理尺寸。如果你在创建时给的是 800 x 600,窗口还是 800 x 600 时一切正常。一旦 User 把窗口拉成了 1200 x 900,后台缓冲还停留在 800 x 600,最后的呈现效果就会拉伸、模糊、变形,严重的直接黑屏或崩溃。
1.2 为什么窗口一变,Swap Chain 就必须重建
后台缓冲不是一块无限大的画布,它是一块和窗口尺寸强相关的 GPU 纹理。窗口客户区从 800 x 600 变成 1200 x 900 后,这两个东西的对应关系就乱了。
普通的 RGBA 纹理可以随便拉伸采样,但 Swap Chain 的后台缓冲在多数图形 API 里是不能简单“Resize”的——严格来说,它要求你把后台缓冲相关的所有资源全部释放,再重新按新尺寸创建一遍。这个“销毁旧的、创建新的”过程,就是标题里说的 Swap Chain Recreation。
网上有些教程会在窗口大小变化时只调用 ResizeBuffers,却不提必须先释放所有对后台缓冲的引用。结果就是调用返回失败,很多人在这里莫名其妙地卡住。
更要命的是,Swap Chain 在设备层面还绑定着一些状态,比如缓冲数量、格式、宽高、是否全屏、是否支持切换输出。任何一项发生变化,都可能需要整条交换链重新来过。尤其在处理全屏切换、多显示器拔插、DPP 缩放变化时,只改宽高是远远不够的。
1.3 哪些场景会触发重建
典型的有六类:
- 窗口尺寸变化
- 屏幕分辨率发生变化
- 用户切换全屏 / 窗口模式
- 显示器的缩放比例(DPI)变化
- 显示器热插拔,例如 HDMI 拔了再插
- GPU 设备丢失(Device Lost / Device Removed / Device Reset)
其中最常见的还是第一类。一个健壮的图形应用,必须把这六类全部纳入 Swap Chain 重建流程。否则用户在全屏切窗口时,就可能看到黑屏;换显示器分辨率时,可能直接设备丢失。
2. 重建 Swap Chain 的标准流程
2.1 核心原则:先释放、再重建、最后更新全部依赖
无论你用哪套图形 API,重建 Swap Chain 的顺序几乎都是固定的,我把它压缩成一条流程:
- 停止提交新的渲染命令。
- 等待 GPU 完成正在执行的命令。
- 释放所有持有后台缓冲引用的对象,尤其是 Render Target View(RTV)、Framebuffer、ImageView。
- 调整或销毁旧的 Swap Chain。
- 在 D3D12/DXGI 里调用 ResizeBuffers,或在新 Swap Chain 创建时把旧的作为参数传入。
- 重新获取后台缓冲,重新创建 RTV / FrameBuffer / DepthBuffer。
- 更新视口(Viewport)和裁剪矩形(Scissor)。
- 恢复渲染循环。
第三步是整个流程里最容易翻车的点。DXGI 明确要求:在调用 ResizeBuffers 之前,应用必须释放后台缓冲的所有直接引用。如果你有一个 ID3D12Resource 指针指着后台缓冲,或者 RTV 描述符引用了它,却没有释放,ResizeBuffers 就会失败,返回 DXGI_ERROR_INVALID_CALL。Vulkan 里类似,旧 SwapChain 的图像可能还挂在渲染通道里,你必须等它们不再被使用。
我见过不少项目是在“重建后”忘了重新获取后台缓冲,导致后台缓冲全部变成空指针,或者 RTV 指向已经释放的资源。这里面的坑,说得再多都不如写一个稳的骨架实在。
2.2 DXGI / D3D 系的标准代码骨架
下面这段代码是一个我常用的 D3D12 风格的 Resize 函数骨架。核心思想就是“先把引用清干净,再 Resize,然后再把引用建回来”。
void ResizeSwapChain(IDXGISwapChain3* swapChain, ID3D12Device* device, ID3D12DescriptorHeap* rtvHeap, ID3D12Resource** backBuffers, UINT bufferCount, UINT width, UINT height) { // 等待 GPU 用完这些资源 WaitForLastSubmittedFrame(); // 释放所有引用后台缓冲的 COm 对象 for (UINT i = 0; i < bufferCount; i++) { if (backBuffers[i]) { backBuffers[i]->Release(); backBuffers[i] = nullptr; } } // 深度缓冲通常也引用了渲染目标,必须一起释放 ReleaseDepthBuffer(); // 重新调整交换链,宽度高度都是新值 HRESULT hr = swapChain->ResizeBuffers( bufferCount, width, height, DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH); if (FAILED(hr)) { // 记录日志 return; } // 重建 RTV 描述符 CD3DX12_CPU_DESCRIPTOR_HANDLE rtvHandle(rtvHeap->GetCPUDescriptorHandleForHeapStart()); for (UINT i = 0; i < bufferCount; i++) { hr = swapChain->GetBuffer(i, IID_PPV_ARGS(&backBuffers[i])); device->CreateRenderTargetView(backBuffers[i], nullptr, rtvHandle); rtvHandle.Offset(1, rtvDescriptorSize); } // 重建深度缓冲和视口 CreateDepthBuffer(width, height); m_viewport = CD3DX12_VIEWPORT(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(height)); m_scissorRect = CD3DX12_RECT(0, 0, static_cast<LONG>(width), static_cast<LONG>(height)); }这里有一个容易被忽略的细节:ResizeBuffers 的 bufferCount 参数。如果之前创建 Swap Chain 时用的是 3 个缓冲,那么 Resize 时传 3 ,一定要和创建时保持一致。否则 DXGI 可能内部重新分配缓冲,表现反而更糟。另一个细节是格式参数,绝大多数情况下,ResizeBuffers 的格式应该和创建时保持一致,尤其别随手改成 DXGI_FORMAT_UNKNOWN,除非你明确想“保持创建时的格式”。
2.3 Vulkan 和 WebGPU 的重建思路
Vulkan 跟 D3D 的套路不太一样。它没有 ResizeBuffers 这种“原地调整”的接口,而是直接要求你“再创建一个新的 SwapChain,创建时带上旧的作为 oldSwapchain 字段,新创建成功后再销毁旧的”。
核心流程是这样的:
// 等待设备空闲,避免旧图像仍被使用 vkDeviceWaitIdle(device); VkSwapchainCreateInfoKHR createInfo{}; createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR; createInfo.surface = surface; createInfo.minImageCount = imageCount; createInfo.imageFormat = surfaceFormat.format; createInfo.imageColorSpace = surfaceFormat.colorSpace; createInfo.imageExtent = extent; createInfo.imageArrayLayers = 1; createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; createInfo.preTransform = caps.currentTransform; createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; createInfo.presentMode = presentMode; createInfo.clipped = VK_TRUE; createInfo.oldSwapchain = oldSwapchain; VkSwapchainKHR newSwapchain; if (vkCreateSwapchainKHR(device, &createInfo, nullptr, &newSwapchain) != VK_SUCCESS) { // 处理失败 } if (oldSwapchain) { vkDestroySwapchainKHR(device, oldSwapchain, nullptr); } // 获取新图像,重新创建 ImageView、DepthBuffer、Framebuffer uint32_t imageCount = 0; vkGetSwapchainImagesKHR(device, newSwapchain, &imageCount, nullptr);注意这里有两个容易误读的点。
第一,vkDeviceWaitIdle不是必须这么粗暴,但重建频率低,用一次全等待是最稳妥的。如果项目里有很多后台任务,更合理的做法是等当前帧的 Fence 信号,再重建。
第二,创建新 SwapChain 后我立刻销毁旧的,但这并不代表命令缓冲里的图像引用已经安全了。如果代码里当前帧还没提交完,就销毁旧图像,后续帧依然可能踩到悬空内存。所以“等待 GPU”必须发生在创建和销毁之前——顺序不能反。
WebGPU 的configureSurface也是同一思路:每次重新配置时传入新的devicePixelRatio或尺寸,内部会根据配置创建新的内部 Swap Chain。所以你在 Web 端做 Resize 时,同样需要在请求动画帧循环里先停止绘制,更新 surface 配置,拿到新纹理后再继续。
3. 重建过程中最容易翻车的细节
3.1 帧同步:别让 GPU 还在用的东西被你先拆了
初学者最容易踩的坑就是“不等 GPU 完事就动手”。你这边刚把后台缓冲 Release 掉,那边 GPU 还在用那个纹理做光栅化,接下来不是崩溃就是花屏。
在 D3D11 里,由于运行时帮你做了更多同步,这个问题容易被掩盖,但换到 D3D12 和 Vulkan 之后,程序员必须自己管理资源生命周期。我建议在 Resize 函数里先做一个“软等待”,去查询上次提交帧的 Fence 是否已经完成。如果没完成,就阻塞等待。
对于重新创建 Swap Chain 的场景,等待的粒度不需要太小。因为 Resize 本身不是高频操作,至少是几十毫秒到几百毫秒一次,用一个阻塞式等待 GPU 完成当前帧,代价完全能接受。
这里还有一个经验:不要每次收到WM_SIZE就立即阻塞等待。用户快速拖拽窗口时,WM_SIZE消息会像洪水一样灌进来,一次等待也许只要几毫秒,但上千次连续等待就会让窗口卡顿。我习惯在窗口消息里先记录“需要 Resize”,然后交给下一帧开始时的逻辑统一处理。
3.2 尺寸为 0 的窗口是最经典的隐雷
窗口最小化时,客户区宽高会变成 0。如果你直接把 0 传给 ResizeBuffers,很多驱动会返回无效参数,另一些驱动会直接崩。
处理方式非常简单:在进入 Resize 函数时先判断宽高是否是 0,如果是 0 ,就只设置一个标志位,跳过这次重建。等窗口恢复原样时,系统会再给你一个合法的WM_SIZE,那时候再去重建。
但也有个很多人没做好的点:最小化后,虽然 Swap Chain 没有重建,你的渲染循环可能还在跑,而且还在尝试 Present。此时如果直接 Present,某些驱动会返回DXGI_ERROR_DEVICE_REMOVED,进而被误判成设备丢失。更稳妥的做法是:最小化时暂停渲染循环,或者用query查询窗口是否可见,不可见时直接跳过帧提交。
3.3 视口、裁剪和相机宽高比是连坐的
Swap Chain 重建之后,后台缓冲的宽高比例如果变了,而你只重建了 RTV、没更新 Viewport,画出来的三角形坐标还是按老尺寸映射的,就会出现奇怪的拉伸或者只画在左上角一小块区域。
正确的做法是把 Viewport 和 ScissorRect 都设置为新的窗口尺寸,时刻保持和 Swap Chain 一致。如果项目的相机投影矩阵使用了宽高比,也要一并更新。很多渲染器里能用到的aspect = width / height,如果不跟着换,物体比例会严重变形,而你查的时候会一头扎进矩阵代码里,根本想不到问题出在 Swap Chain 重建。
另外,如果启用了后处理管线(Bloom、景深之类),这些后处理阶段的全屏三角形或采样 UV 坐标,也需要以新的尺寸为准。后处理缓冲尺寸和后台缓冲不同步,最常见的结果就是画面边缘出现硬边,或者模糊范围错位。
3.4 全屏切换、DPI 变化,重建条件不一样
全屏切换不是简单把窗口变大,而是需要让 Swap Chain 真正进入独占全屏或窗口化全屏模式。在 DXGI 下,通常需要调用IDXGISwapChain::SetFullscreenState,或者使用DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH标志位。
DPI 变化稍微特殊一点。Windows 系统里 DPI 变化的通知通常是WM_DPICHANGED,它的处理里建议直接MoveWindow到系统推荐的新尺寸,然后跟着走一次WM_SIZE的换链重建流程。如果你只监听WM_SIZE不监听 DPI 变化,在高分屏上切换缩放比例后,窗口客户区虽然变了,但你的视图不会正确匹配,画面会模糊。
还有一个小技巧:在创建 Swap Chain 时,尽量不要写死width和height,而是每次从GetClientRect读取当前实际大小。这样即使系统因为 DPI 缩放改变了客户区大小,你重建时用的数值始终是真实的,不会因为拿了一个旧的全局变量而重建出一个和窗口不匹配的 Swap Chain。
4. 常见问题与排查技巧实录
4.1 一拖动就黑屏、闪烁、画面撕裂,通常不是 GPU 的问题
很多人遇到黑屏后,第一反应是查着色器、查顶点布局、查贴图加载。但如果是“只在窗口变化时黑屏”,那百分之九十是 Swap Chain 重建没做好。
黑屏最常见的原因是:后台缓冲没有成功从老尺寸过渡到新尺寸。可能是你调用 ResizeBuffers 时,旧的 RTV 引用没释放干净导致调用失败;也可能是你已经成功 Resize,但画面绘制时还在使用旧的后台缓冲索引,获取新缓冲后没有重新绑定到渲染目标。
闪烁的原因则更隐蔽:窗口快速拉伸时,DWM(桌面窗口管理器)可能还在用旧缓冲区做合成,而你这边已经把旧 Swap Chain 销毁了。这种情况下的闪烁,往往可以通过“延迟一两个 Present 再销毁旧资源”来缓解。但不是每个应用都必须处理到这种粒度,关键是先确认 Resize 本身没有失败,再考虑同步策略。
画面撕裂是和垂直同步相关的。Resize 时如果你重新设置了 presentMode,尤其从 Fifo 换成 Mailbox,会改变同步策略。高频切换模式下,撕裂概率明显上升。排查时先确认你的 Present 参数里有没有带DXGI_PRESENT_VSYNC或者 Vulkan 里 PresentMode 是否被意外重置。
4.2 遇到 DXGI_ERROR_DEVICE_REMOVED 该怎么办
这可能是 Swap Chain 相关错误里最吓人的一个。它并不是 Swap Chain 本身出问题,而是显卡驱动挂了、GPU 被重置,或者显卡因为超频不稳定,导致整个设备对象不再有效。
遇到这种错误,很多人试图直接调用 ResizeBuffers 把它救回来,这是不可能的。正确做法是:
- 拿到
DXGI_ERROR_DEVICE_REMOVED/DXGI_ERROR_DEVICE_RESET。 - 调用
IDXGIDevice::GetDeviceRemovedReason分析根本原因。 - 释放所有和 D3D 设备相关的资源,包括 Swap Chain、Command Queue、Fence、Shader 等。
- 重新创建设备和 Swap Chain,重新加载资源。
- 如果无法恢复,做好日志,给用户弹一条“图形设备重置”的提示。
Vulkan 里对应的错误码是VK_ERROR_DEVICE_LOST。处理思路类似:重建逻辑必须从 Vulkan 实例级的 device 开始,不要试图只恢复 Swap Chain。设备一丢,所有上游资源都不可信了。
一个实用的经验是:把“设备重建”封装得足够独立,不要和普通窗口 Resize 混在一起。窗口 Resize 只需要重建 Swap Chain 相关对象;设备丢失需要重建整个渲染器。如果两个流程共用一套代码,你会很容易在状态上互相污染。
4.3 Vulkan 的 oldSwapchain 为什么明明销毁了还是崩溃
Vulkan 初学者常犯的一个错误是:创建完新 SwapChain 后,立刻销毁旧 SwapChain,结果后面一帧突然崩溃。
原因在于新 SwapChain 虽然创建成功了,但你的渲染循环里可能保存着旧 SwapChain 的 ImageView、FrameBuffer,或者有命令缓冲仍然引用了旧图像。销毁旧 SwapChain 时,这些资源并没有跟着自动销毁,它们还在用已经失效的内存。
正确处理顺序是:
- 先把旧 SwapChain 作为
oldSwapchain传进新 SwapChain 的创建结构体,目的是让驱动可以复用部分内存。 - 新 SwapChain 创建成功后,获取新的 images。
- 创建新的 ImageView、Framebuffer、Render Pass,等所有依赖都切换到新对象。
- 等 GPU 完全空闲后,再销毁旧 SwapChain,以及旧 ImageView 和旧 Framebuffer。
很多官方示例把vkDeviceWaitIdle放在“创建新 swapchain”之前,道理就在这里。它保证后续的销毁和重建都不会撞上正在执行的帧。
4.4 一份可以照抄的快速排查表
| 现象 | 可能原因 | 建议排查方式 |
|---|---|---|
| 拖动窗口黑屏 | ResizeBuffers 返回失败,通常是旧 RTV 未释放 | 在代码里检查返回 HRESULT,确保 RTV 引用清零 |
| 窗口恢复时崩溃 | 最小化阶段没有处理 0 尺寸 | 对宽高为 0 直接 return,暂停渲染循环 |
| 画面拉伸变形 | Viewport / Scissor 没更新 | 使用新宽高重建视口和裁剪矩形 |
| 物体比例不对 | 相机 aspect 没有更新 | 同步更新投影矩阵宽高比 |
| 全屏切换后闪烁 | 没有正确设置 fullscreen state | 检查DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH和 SetFullscreenState |
| 设备丢失 | GPU 重置或驱动异常 | 捕获DXGI_ERROR_DEVICE_REMOVED,触发整套设备重建 |
| 新 SwapChain 创建后马上崩溃 | oldSwapchain 销毁过早 | 等 GPU 空闲后重建 ImageView 和 FrameBuffer 再销毁旧资源 |
| DPI 变化后画面发虚 | 没有监听WM_DPICHANGED | 处理 DPI 变化并强制重建一次 Swap Chain |
这张表里的每一条,我都实际踩过。尤其是第一条,最初我甚至怀疑是显卡驱动 bug,后来才发现是 Release 顺序错了。
最后讲一点我的个人习惯
现在我写图形代码,不会等到窗口变化才想到 Swap Chain,而是在工程一开头就把“尺寸变化”当成一种常态去设计。所有跟渲染尺寸相关的资源,都通过一个统一的 Resize 入口去更新;所有已经在 GPU 上排队的命令,在 Resize 前都强制等到 Fence 完成。这样,哪怕后面同时遇到全屏切换、DPI 变化、多显示器插拔,我只需要在收到消息后调用同一个入口,基本不会出问题。
还有一个很小的技巧:每次重建 Swap Chain 之前,我都会顺手打一条日志,记录旧尺寸、新尺寸、Resize 是否成功。这在排查模糊难解的黑屏问题时,能省下大量时间。日志这东西看起来土,但在图形崩溃现场,它是帮你缩小范围最快的手段。如果你也正在被 Swap Chain 重建折磨,先把 Resize 日志加上,再按本文顺序检查一遍,大概率没跑。