ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SharpDX DX11 Winform 渲染骨架:交换链、消息泵与黑屏排查

SharpDX DX11 Winform 渲染骨架:交换链、消息泵与黑屏排查

简介:「SharpDX第一个Winform窗口」是一份面向 C# 图形开发初学者与 DirectX 入门者的实战源码工程,演示如何在 Windows Forms 中用 Panel 控件承载 Direct3D 11 渲染,绕开内置窗口系统完成首个 3D 三角形绘制。压缩包共 77 个文件、约 3.52MB,主体是 SharpDX 各模块的 dll 及其配套 xml 接口文档,辅以 9 个 cs 源文件、csproj 与 sln 工程文件、exe 与 pdb 调试产物以及 MiniTri.fx 着色器,能够还原从设备与上下文创建、交换链与渲染目标视图绑定、顶点与索引缓冲,到管线状态设置和 Present 呈现的完整链路。已有 1901 人学习下载,适合希望从零理解 Direct3D 11 管线,并在 Winform 项目里实现 3D 渲染的开发者。读者可对照源码逐步调试三角形绘制流程,理清 Panel 作为呈现目标时的定制方式与深度缓冲配置,为后续复杂图形和游戏开发打下基础。

1. 为什么还要拿 SharpDX 起一个 Winform 窗口:一份归档库的可用边界

手上有个老项目要在 Winform 里画实时曲线和点云,图形那边的同事扔过来一套 SharpDX DX11 C# Winform 源码,跑起来是黑屏,注释比代码还少。这种情况我遇到过不止一次。SharpDX 这个库早就归档了,但它在 .NET 桌面程序里接 DX11 依然是最短的一条路——你不需要懂 C++,不需要处理 COM 头文件,拿到 HWND 就能建交换链。

这套骨架真正干的事就三件:拿窗口句柄、建交换链、让 Present 跟着消息泵转起来。适合三类人:要在业务界面里嵌一块高性能绘制区域的开发者、接手了 .NET 图形老代码的维护者、想先把 DX11 初始化流程跑通再谈渲染逻辑的新手。后面几章按「跑起来 → 跑对 → 不崩」推。

2. 环境与工程骨架:包、目标框架和窗口句柄先对齐

2.1 依赖清单与目标框架到底怎么定

SharpDX 的包是按子系统拆开的,不是一个包全包。你只建一个窗口清屏,也需要核心包加上 D3D11 和 DXGI 这两个。下面这张表是我建工程时实际会引的,多引了会拖慢启动,少引了编译期就报「命名空间不存在」:

包名作用这个骨架是否必需
SharpDX基础类型、COM 对象基类、异常封装必需
SharpDX.DXGI交换链、适配器、输出枚举必需
SharpDX.Direct3D11设备、设备上下文、纹理、视图必需
SharpDX.MathematicsColor4、矩阵等值类型必需(清屏色用到)
SharpDX.D3DCompilerHLSL 编译第一次跑骨架不需要,正式加渲染再引
SharpDX.WindowsRenderLoop、消息循环辅助可选,取决于你用哪套循环

目标框架方面,我一般直接选 .NET Framework 4.7.2 或 4.8。原因很实在:SharpDX 是围绕 .NET Framework 的 COM 互操作机制设计出来的,它大量依赖ComObject的引用计数和Marshal的句柄转换,在 .NET 上还能跑,但踩到边界问题的时候,你会在一个和渲染毫无关系的地方卡两天。如果你所在的环境已经全面 .NET,也可以试,只是心里要有数——出问题不一定有人回答。

平台目标是个更隐蔽的坑。Winform 模板默认 AnyCPU,而 SharpDX 的托管程序集里带着原生资源的加载逻辑,AnyCPU 在 64 位系统上跑成 64 位进程,通常是没问题的,但一旦你把工程往 x86 的旧机器上拷,或者混进去一个只提供 x86 原生部分的第三方库,就会变成BadImageFormatException。我的习惯是新建工程第一件事就把平台目标显式定成 x64,把「预编译」里那个 AnyCPU 勾掉,别给它自己判断的机会。血泪经验是:这个异常的表象是「未能加载文件或程序集」,很容易被误判成缺包,实际是位数不对。

还有一个小动作,NuGet 还原完之后,检查一下输出的 bin 目录里有没有SharpDX.Direct3D11.dll、SharpDX.DXGI.dll、SharpDX.dll这三个。少任何一个,运行时都会在new Device(...)这一行抛DllNotFoundException,而不是在编译期告诉你。这属于「黑匣子」型故障,越早确认越好。

2.2 窗体侧的准备:ClientSize、Handle 和 DPI

交换链描述里的宽高用的是像素,而 Winform 的ClientSize默认是逻辑像素。这一层差异在 100% 缩放的机器上看不出来,一到 125% 或 150% 的笔记本上,画面就会被拉伸,或者右下角露一条黑边。处理方式有两种,我倾向后者:

第一种是在 app.manifest 里把 DPI 感知关掉,让系统帮你缩放整个窗口。省事,但渲染出来的东西会糊,字也糊,不适合做曲线。

第二种是声明 PerMonitorV2 的 DPI 感知,然后自己在拿到客户区尺寸时按DeviceDpi换算。这个方案要多写几行,但画面清晰度是对的。

<!-- app.manifest 片段:声明进程级别的 DPI 感知 --> <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application>

这段 manifest 的作用是让进程不被系统做位图拉伸,窗口拿到的是真实像素尺寸。加上之后,ClientSize的数值在大多数情况下就和物理像素一致了,你不需要再手动乘缩放系数。注意true/pm和PerMonitorV2两行要一起写,只写一行在部分系统上会退化成系统级感知。

比 DPI 更容易踩的是句柄时机。Form.Handle这个属性是个「懒创建」的陷阱:你在构造函数里读它,它会立刻触发窗口句柄创建,而此时窗体的布局、尺寸、样式都还没最终定下来,后面你再改ClientSize或者FormBorderStyle,有些设置会失效,行为很怪。我在初始化设备的时候一律放到OnLoad之后,或者更保险地放在OnShown里,这时句柄一定是有效的、尺寸也一定是最终的。

protected override void OnShown(EventArgs e) { base.OnShown(e); // 到这一步句柄已经真实存在,ClientSize 也已经是最终值 if (this.ClientSize.Width <= 0 || this.ClientSize.Height <= 0) return; InitializeDevice(); Application.Idle += OnApplicationIdle; // 循环在设备建好之后再挂 }

这里有两个细节值得说。一是OnShown里做设备初始化,能保证渲染的第一帧尺寸是对的,不会出现「启动时窗口大小还没定,交换链按默认尺寸建好了,随后立刻要 Resize」这种开局就翻车的情况。二是Application.Idle的挂载放在设备初始化之后,避免设备还是 null 的时候循环已经开始跑,第一帧直接空引用。这两个顺序颠倒过来,报的错都很莫名其妙。

另外,OnShown在窗口最小化恢复时不会重复触发,所以不用担心重复建设备。但如果你在窗体上放了SplitContainer或者TabControl,它们的尺寸变化会触发ClientSizeChanged,这时候的尺寸变化和你想象的窗口拖拽是一样的,后面的 Resize 逻辑要能扛住这种高频调用。

3. 交换链初始化:把 DX11 的十来个参数逐个填对

3.1 Device 与 ImmediateContext 的创建顺序

D3D11 的初始化链路是:先有交换链描述,再调Device.CreateWithSwapChain一次把设备、上下文和交换链都建出来,然后从交换链里取后台缓冲、建渲染目标视图。

顺序不能乱。有些人习惯先new Device(...)再单独new SwapChain(...),那样要自己拿 DXGI 工厂、自己填工厂描述,代码量翻倍,还要处理工厂和设备之间的适配器一致性问题。CreateWithSwapChain这个重载内部帮你把这些都串好了,第一次跑骨架没必要绕远路。

using SharpDX; using SharpDX.Direct3D; using SharpDX.Direct3D11; using SharpDX.DXGI; // 两个命名空间里都有 Device 和 Buffer 这类同名类型,必须起别名,否则一片红 using Device = SharpDX.Direct3D11.Device; using Buffer = SharpDX.Direct3D11.Buffer; private Device _device; private DeviceContext _context; private SwapChain _swapChain; private RenderTargetView _renderTargetView; private const int BufferCount = 2;

别名这一行不是洁癖。SharpDX.DXGI里有Device和Surface,SharpDX.Direct3D11里有Device和Texture2D,两个using一写,编译器就会在new Device(...)那一行报「二义性引用」。起别名是最省事的解法,比到处写全限定名干净。

DriverType.Hardware会走独立显卡或集显的真实驱动。如果机器跑在远程桌面、虚拟机或者某些云桌面上,硬件驱动拿不到,就会抛DXGI_ERROR_UNSUPPORTED。这种环境下换成DriverType.Warp,也就是软件光栅化器,能跑起来,帧率低但画面是对的。我一般会在初始化失败时做一次降级重试,而不是直接弹错误框。

3.2 SwapChainDescription 逐字段拆解

这一段是整套代码里最容易抄错的地方。下面这张表是我按字段整理的,每一条都对应一个会真实发生的失败:

字段推荐值填错的后果
BufferCount2填 1 在部分驱动上 Present 会阻塞,掉帧明显;填 3 以上内存占用上升,首帧延迟变高
ModeDescription.Width/Height客户区像素尺寸和实际客户区不一致,画面拉伸或留黑边
ModeDescription.FormatB8G8R8A8_UNorm换成R8G8B8A8_UNorm不带 BgraSupport 会创建失败
ModeDescription.RefreshRate60/1 随便填窗口模式下被忽略,填了不报错,容易让人误以为生效了
IsWindowedtrue填 false 会尝试独占全屏,调试期极易卡死,出不来
OutputHandlethis.Handle句柄无效会建到桌面窗口上,渲染内容直接看不见
SampleDescription(1, 0)填(4, 0)在很多驱动组合下直接返回DXGI_ERROR_INVALID_CALL
SwapEffectDiscard第一次跑用这个最稳,换 Flip 模型要改工厂创建方式
UsageRenderTargetOutput漏填则无法建 RTV,new RenderTargetView抛参数异常
FlagsNone除非要做 GDI 共存或特殊共享,不要乱加

关于采样数,常见做法是第一版老老实实写(1, 0),需要抗锯齿的时候走「先渲染到一张开了 MSAA 的离屏纹理,再ResolveSubresource到后台缓冲」这条路。直接在交换链上开 MSAA,只有新式翻转模型才稳妥支持,而翻转模型在 SharpDX 里要走 DXGI 工厂创建,代码结构会变,不适合当第一个窗口。

关于SwapEffect.Discard,它属于 BitBlt 模型,兼容性最好,代价是每次 Present 之后后台缓冲内容就「作废」,你不能指望下一帧还在上一帧的基础上继续画。这个语义要记住,很多人写累加式渲染的时候画面闪烁,就是因为默认了缓冲会保留。

private void InitializeDevice() { var clientSize = this.ClientSize; var swapChainDesc = new SwapChainDescription { BufferCount = BufferCount, ModeDescription = new ModeDescription( clientSize.Width, clientSize.Height, new Rational(60, 1), // 窗口模式下被忽略,但必须给个合法值 Format.B8G8R8A8_UNorm), // 与 GDI 共存的格式 IsWindowed = true, OutputHandle = this.Handle, // 句柄此时必须已经有效 SampleDescription = new SampleDescription(1, 0), // 第一版不做 MSAA SwapEffect = SwapEffect.Discard, Usage = Usage.RenderTargetOutput, // 不加这句建不出 RTV Flags = SwapChainFlags.None }; // 第三个参数是设备创建标志,BgraSupport 决定了上面那个 B8G8R8A8 格式能不能用 Device.CreateWithSwapChain( DriverType.Hardware, DeviceCreationFlags.BgraSupport, swapChainDesc, out _device, out _swapChain); _context = _device.ImmediateContext; }

DeviceCreationFlags.BgraSupport这个标志位是最典型的翻车点。它的作用是告诉 D3D 运行时,这个设备的渲染目标要能和 GDI 的 BGRA 顺序共存。不加,Format.B8G8R8A8_UNorm就是不可用格式,CreateWithSwapChain直接抛SharpDXException,错误码DXGI_ERROR_INVALID_CALL,而异常信息里不会告诉你「是因为少了这个标志」。我见过有人在这卡了一整天。

调试期还可以加上DeviceCreationFlags.Debug,配合本机装好的调试层,能在输出窗口里看到 D3D 的警告,比如资源没释放、格式不匹配。注意这个标志在没装调试层的机器上会让设备创建直接失败,所以只能在开发机上开,不能带进发布包。

3.3 后台缓冲、RTV 与视口

交换链建好之后,后台缓冲本身还不能直接画。你要从交换链里把索引 0 的后台缓冲取出来,包成RenderTargetView,再绑到输出合并阶段。

// 后台缓冲用完必须释放,否则后面的 ResizeBuffers 会一直失败 using (var backBuffer = _swapChain.GetBackBuffer<Texture2D>(0)) { _renderTargetView = new RenderTargetView(_device, backBuffer); } // 输出合并阶段绑定渲染目标 _context.OutputMerger.SetRenderTargets(_renderTargetView); // 视口必须单独设置,不设就是全零,画面什么都不出来但也不报错 _context.Rasterizer.SetViewport(0, 0, clientSize.Width, clientSize.Height, 0f, 1f);

GetBackBuffer<Texture2D>(0)这里返回的Texture2D是SharpDX.Direct3D11命名空间下的类型,不是 DXGI 的 Surface。如果你用GetBackBuffer<Surface>(0)再转Texture2D,也能过编译,但多一次查询接口,而且两者生命周期语义不同,容易在释放的时候出问题。

视口这一行是另一个「不报错但没画面」的经典。D3D11 的默认视口在设备创建后是全零尺寸,你不设置,光栅化阶段会把所有图元裁掉,结果就是清屏颜色正常(因为清屏是直接作用于 RTV 的,不走视口),但画什么都看不见。很多人第一次画三角形,看到的是背景色,第一反应是着色器写错了,实际上问题在视口。

还有个容易忽略的点:using包住的backBuffer在离开作用域时会被释放,但 RTV 内部已经持有了对它的引用,所以 RTV 依然有效。这个引用计数机制是 SharpDX 帮你管的,不用手动 AddRef,但也意味着 RTV 不释放,后台缓冲就不会真正回收——后面 Resize 那一章会再提到这一点。

4. 渲染循环:让 Present 和 Winform 消息泵共存

4.1 两套循环:RenderLoop.Run 与 Application.Idle

SharpDX 提供了SharpDX.Windows.RenderLoop,用法极简:

using SharpDX.Windows; static void Main() { var form = new MainForm(); // Run 内部自己跑消息循环,回调里做渲染 RenderLoop.Run(form, () => form.Render()); }

它内部做了两件事:在没有消息时反复调用回调,在退出时结束循环。写法短,但生命周期的接管感很强——它会自己调用窗体的Show(),也就是说窗体的显示时机从你手上转移到了它手上。如果你在窗体设计器里挂了Load、Shown事件,时序和你预期的不太一样,尤其是那些在Shown里做数据初始化的业务代码,可能跑到渲染循环启动之后。

我自己的选择是Application.Idle这套:

private void OnApplicationIdle(object sender, EventArgs e) { // 只要消息队列是空的,就连续渲染,直到有消息进来 while (IsApplicationIdle()) { Render(); } }

这个模式的好处是和 Winform 的既有结构完全贴合,窗体上的按钮、菜单、定时器都不受影响,也不需要改Program.Main。代价是你要自己写空闲判断。

要注意Application.Idle是静态事件,是进程级别的。如果你有多个窗口都挂了这个事件,切换窗口的时候会互相干扰,可能出现一个窗口在前台,另一个窗口的渲染循环还在跑。单窗口场景没事,多窗口的话要么在Deactivate里解绑,要么改用每窗口一个独立渲染线程——但那是另一个复杂度量级了,第一个窗口不建议碰。

4.2 手写 PeekMessage 的空闲判定

IsApplicationIdle不是 Winform 自带的,得自己用 P/Invoke 包一层:

using System; using System.Drawing; using System.Runtime.InteropServices; [StructLayout(LayoutKind.Sequential)] private struct NativeMessage { public IntPtr Handle; public uint Message; public IntPtr WParam; public IntPtr LParam; public uint Time; public Point Location; } [DllImport("user32.dll", CharSet = CharSet.Auto)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool PeekMessage( out NativeMessage msg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg); private const uint PM_NOREMOVE = 0; private static bool IsApplicationIdle() { // 队列里有消息返回 true,取反后表示空闲; // PM_NOREMOVE 保证我们只是「看一眼」,不把消息抢走 return !PeekMessage(out NativeMessage _, IntPtr.Zero, 0, 0, PM_NOREMOVE); }

PM_NOREMOVE这个标志是整个模式的关键。它让PeekMessage只探查不移除,消息仍然留在队列里,随后 Winform 的消息泵会正常把它取走分派。如果你图省事用PM_REMOVE,那拖拽窗口、点击按钮这些消息会被你吃掉,界面直接失去响应,看起来就像假死。

hWnd传IntPtr.Zero表示查当前线程的所有窗口消息,这和 Winform 单 UI 线程的模型是一致的。如果你想严格限制只检查本窗体的消息,可以传this.Handle,但那样会漏掉线程级的消息,反而容易出问题,不建议。

while循环里连续调用Render(),意味着这一帧渲染完没有消息就一直渲染下一帧。这样会把 CPU 和 GPU 打满,所以必须靠Present的同步参数来限速,否则你的机器风扇会告诉你什么叫「1000 帧每秒的空转」。

4.3 清屏、Present 与帧率控制

private void Render() { // 设备可能在关闭过程中已经被释放,循环还没解绑时要做这层保护 if (_context == null || _context.IsDisposed || _swapChain == null || _swapChain.IsDisposed) return; var clearColor = new Color4(0.12f, 0.12f, 0.14f, 1f); _context.ClearRenderTargetView(_renderTargetView, clearColor); // 第一个参数是同步间隔,1 表示等待垂直同步,0 表示立即呈现 _swapChain.Present(1, PresentFlags.None); }

Present的第一个参数不是「帧数」,是同步间隔,取值 0 到 4。填 1 的效果是这一帧要等到显示器垂直回扫才返回,天然把帧率锁在刷新率上,同时避免画面撕裂。填 0 会立刻返回,帧率能跑到几百甚至上千,画面在水墨风曲线这种场景下撕裂感很明显。

这里有个很玄学的现象:某些显卡驱动在窗口模式下对Present(1)的处理会退化成 0,你看起来限帧没生效。判断方法是拿一个Stopwatch统计一秒内的帧数,如果远超刷新率,那就是驱动把同步忽略了,只能自己在循环里加Thread.Sleep或者用定时器节流。别怀疑代码写错了,先测再说。

另外一个容易踩的点是Present在窗口被遮挡或者最小化时的行为。窗口最小化后,有些驱动会让Present阻塞很久,UI 线程就卡在那里,表现为「程序没死但点不动」。处理办法是在Render开头判断this.WindowState == FormWindowState.Minimized或者!this.Visible,直接返回,不要调Present。

还有PresentFlags.None这个参数,除非你要做帧统计或者测试,不要加PresentFlags.DoNotWait之类的标志。加了之后 Present 不等待,队列满的时候会返回DXGI_STATUS_OCCLUDED或者直接丢弃,画面表现是间歇性卡顿,排查起来很费劲。

5. 避坑排查:黑窗口、拖动假死与退出崩设备

5.1 启动直接抛 SharpDXException:先查 BgraSupport 和采样数

现象很直接:窗体出来了,但InitializeDevice那一行抛出SharpDXException,ResultCode是DXGI_ERROR_INVALID_CALL或者DXGI_ERROR_UNSUPPORTED,异常信息里没有任何有用的上下文。

原因通常有两个。一是DeviceCreationFlags里漏了BgraSupport,而你用的格式是B8G8R8A8_UNorm,D3D 认为这个格式不可用于渲染目标,于是整条创建链路失败。二是SampleDescription填了大于 1 的采样数,而SwapEffect用的是Discard,某些驱动组合下这个组配不被接受。

解决办法是把创建过程拆成可诊断的形式:先把SampleDescription改成(1, 0),加上BgraSupport,跑一次;能过就说明是采样数的问题,再决定要不要上离屏 MSAA。如果还不行,把DriverType.Hardware换成DriverType.Warp再试一次——Warp 能过说明是驱动或环境问题(远程桌面、虚拟机最常见),Hardware 过不了就老老实实降级。

5.2 拖动窗口就假死:Present 卡住了消息泵

现象是窗口能正常渲染,鼠标一按住标题栏拖动,整个界面就僵住,松手之后才恢复,有时候还会留下一条白影。

原因是渲染循环跑在 UI 线程上,Present(1, ...)在等垂直同步时会阻塞当前线程。拖动窗口的时候系统进入一个模态的移动/缩放循环,会持续产生消息,PeekMessage一直返回有消息,IsApplicationIdle返回 false,循环就退出了——听起来应该没事。真正出问题的是反过来:Present阻塞期间,消息泵无法处理「重绘」消息,Windows 认为这个窗口无响应,给它盖上一层白色蒙版。

解决方向有两个。轻量的做法是拖动期间降低渲染频率,监听ResizeBegin和ResizeEnd(或者WM_ENTERSIZEMOVE/WM_EXITSIZEMOVE),在拖动开始时把循环停掉,只保留消息处理,松手后再恢复。彻底的做法是把渲染挪到独立线程,UI 线程只负责消息,两者靠共享的交换链通信——但那要处理线程同步和DeviceContext的线程安全,第一次接触不建议直接上。

5.3 关闭窗口崩在 Dispose:释放顺序反了

现象是点右上角关闭,程序不退出,任务管理器里还留着一个进程,或者直接抛AccessViolationException、NullReferenceException,堆栈落在Present或Dispose里。

原因有两个层面。第一层是事件没解绑:Application.Idle是静态事件,窗体关了但事件还挂着,循环继续跑,Render里访问的_swapChain已经被释放,于是空引用。第二层是释放顺序:先释放交换链、再释放 RTV,RTV 释放时会去访问已经销毁的后台缓冲,触发访问违例。

正确的顺序是「先停止使用、再从外到内释放」:解绑Application.Idle→_context.ClearState()解除所有绑定 → 释放 RTV → 释放交换链 → 释放上下文 → 释放设备。ClearState这一步很多人不知道,它的作用是让上下文把所有绑定的资源引用都放掉,不做这一步,RTV 的引用计数降不到 0,交换链就释放不干净,进程会残留。

5.4 缩放后画面拉伸或闪黑:ResizeBuffers 没接上

现象是拖动窗口边框改变大小,画面要么保持原尺寸被拉伸,要么右下角一片黑,快速拖动时还会闪。

原因是交换链的后台缓冲尺寸是创建时定死的,窗口变大变小它不会自动跟着变。你必须显式调用ResizeBuffers,并且在这之前把所有引用后台缓冲的对象都释放掉,包括 RTV、任何ShaderResourceView、任何拷贝出去的纹理。有一个没释放,ResizeBuffers就返回DXGI_ERROR_INVALID_CALL,而很多人把这个返回值忽略了,于是画面停在上一个尺寸上,看起来就是拉伸。

另外_context.Rasterizer.SetViewport也要在 Resize 之后重新设一次,否则视口还是老尺寸,渲染区域和窗口对不上。这一条在 6.1 里展开。

5.5 换台机器就 DllNotFoundException:平台目标和依赖没拷全

现象是在开发机上好好的,拷到另一台机器上,双击就报「未能加载文件或程序集 SharpDX.Direct3D11」或者「试图加载格式不正确的程序」。

原因有两类。一是 bin 目录没拷全,SharpDX.dll、SharpDX.DXGI.dll、SharpDX.Direct3D11.dll少一个,运行时才报错,编译期完全正常。二是平台目标不一致,开发机是 x64 进程,目标机器上因为某种原因跑成了 x86,或者反过来。

解决办法是把输出目录当交付物对待:发布前用xcopy或者构建脚本把整个bin\Release带走,不要只拷 exe。平台目标在工程属性里显式锁死,别用 AnyCPU。如果确实要做双平台,就在配置管理器里建两套平台配置,各自输出到不同目录,避免混在一起。

6. 缩放与设备丢失:一份能自查泄漏的收尾清单

窗口缩放这块,关键代码就一段,但每一行都不能省:

protected override void OnClientSizeChanged(EventArgs e) { base.OnClientSizeChanged(e); // 最小化时客户区可能是 0,此时调 ResizeBuffers 必失败 if (_swapChain == null || _swapChain.IsDisposed || this.WindowState == FormWindowState.Minimized) return; var size = this.ClientSize; if (size.Width <= 0 || size.Height <= 0) return; // 必须先把绑定解除并释放 RTV,否则 ResizeBuffers 直接返回 INVALID_CALL _context.OutputMerger.SetRenderTargets((RenderTargetView)null); _renderTargetView.Dispose(); _swapChain.ResizeBuffers(BufferCount, size.Width, size.Height, Format.B8G8R8A8_UNorm, SwapChainFlags.None); using (var backBuffer = _swapChain.GetBackBuffer<Texture2D>(0)) { _renderTargetView = new RenderTargetView(_device, backBuffer); } _context.OutputMerger.SetRenderTargets(_renderTargetView); _context.Rasterizer.SetViewport(0, 0, size.Width, size.Height, 0f, 1f); }

设备丢失是另一类问题:显卡驱动升级、外接显卡被拔、GPU 计算超时,都会让Present抛出SharpDXException,ResultCode是DXGI_ERROR_DEVICE_REMOVED或DXGI_ERROR_DEVICE_RESET。这种错误没法在原来的设备上恢复,只能把从Device到RenderTargetView整条链全部释放,然后重新走一遍InitializeDevice。我在Render的try/catch里做了这个判断,重建时给一个重试上限,连续失败三次就提示用户并停止循环——否则会陷入「崩一次重建一次」的死循环。

验证有没有泄漏,最省事的办法是在退出前打开调试层报告:

// 需要设备创建时带上 DeviceCreationFlags.Debug,且本机装了调试层 using (var debug = _device.QueryInterface<DeviceDebug>()) { debug.ReportLiveDeviceObjects(ReportingLevel.Detail); }

输出窗口里会列出所有还活着的对象和它们的引用计数。我在收尾阶段跑一次,如果看到交换链、RTV、纹理还在列表里,说明有地方没释放,回头查顺序,比用任务管理器看内存涨没涨靠谱得多。记住这个报告要在最外层Dispose之前调用,设备释放完就没得查了。

从那以后我每次新建 SharpDX 工程,都会先把「句柄在 OnShown 拿、BgraSupport 必带、Present 加最小化保护、退出先解绑 Idle 再 ClearState」这四件事按顺序写进模板,再开始写业务渲染。少一条,后面都要花半天找。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进