简介:基于C#与DirectX的直线绘制性能测试项目,通过SlimDX或SharpDX封装调用DirectX接口,测量画直线的具体耗时,适合C#图形编程学习者、游戏开发入门者以及关注渲染性能的开发者。资源共20个文件,涵盖5个C#源码、3个可直接运行的exe、2个pdb调试符号、2个缓存文件,以及解决方案sln、工程csproj、窗体资源resx等配置,压缩包仅38KB,结构轻量,便于快速研读和二次开发。目前已有141人学习下载。项目完整演示了DirectX设备初始化、直线绘制与着色器调用、Stopwatch计时、窗体交互及渲染循环等关键环节,可帮助读者直观掌握C#环境中调用非托管图形接口的流程,并据此定位绘制性能瓶颈。通过阅读源码,还能学习顶点缓冲区构建与计时测试写法,并将相关代码迁移到自己的C#图形项目中。
1. 为什么C#的DX控件画一条线还要单独测速度
CSharp-test-DX-draw-a-line-speed 这个标题,看着像某个测试源码包,但说到底它就是一件事:在 C# 的 DX(DevExpress)控件上画一条线,并且把“画得快不快”量化出来。DX 控件的绘制层和普通 WinForms 控件不一样,自带缓存、皮肤和坐标转换,一句 DrawLine 在不同版本和 DPI 下耗时能差出好几倍。这适合两类人:新手想知道这线到底画在哪、怎么让 DX 控件响应绘制;熟手则想把单线耗时做成基准,用来评判绘图方案改不改。下文直接从绘制模型讲到测速和避坑,每个步骤都能复制到本地跑通。
2. 先把绘制模型搞清楚:DX控件里的画线发生在哪一步
2.1 DX控件的绘图栈:真正执行DrawLine的到底是谁
先分清一个事情:C# 圈子里的 DX 几乎都是指 DevExpress 那套 WinForms 控件,不是 DirectX。我之前看到有人拿“dx修复工具”当关键词搜到这个标题,结果越看越不对。DevExpress 的控件以外观统一、功能覆盖出名,最常见的有 PanelControl、GridControl、ChartControl;它们不是同一个绘制实现,但都统称为 DX 控件。
真正执行 DrawLine 的仍然是你熟悉的 Graphics 对象,区别在于这个 Graphics 是从哪里来的。普通控件在 Paint 事件里拿到e.Graphics;而 DX 控件除了 Paint,还有一层 CustomDraw 的管线,例如 GridView 的自定义绘制、TileView 的 ItemCustomize。它们会在控件内部把自己的图元画完后再把剩余绘制交给你,所以你说“在 DX 控件上画线”,先要回答一个问题:是在控件自身的层上画,还是在 DX 内部某个元素(单元格、图表坐标区)上画。两条路径的执行时机不同,测出来的数值没有可比性。
最常见的做法,也是大多数项目里最省事的方式,是拿一个 DX 的 PanelControl 当自定义绘制容器,挂它的 Paint 事件,在里面画直线。这条路径最接近普通 WinForms 的绘制经验,也比较容易和标题里的“draw a line speed”对应起来。下面我给出的示例都围绕这条路径展开。
2.2 最小可运行示例:在DX的PanelControl上画一条线
下面这个类继承DevExpress.XtraEditors.PanelControl,在构造函数里挂 Paint。代码在 .NET Framework 4.8 和 .NET 6+ 上都能跑,前提是项目已经安装了 DevExpress WinForms 套件并引用了对应程序集。版本号我没有写死,以你项目实际安装的 DX 版本为准。
using System; using System.Drawing; using DevExpress.XtraEditors; public class DxLinePanel : PanelControl { public DxLinePanel() { Paint += OnDxLinePaint; } private void OnDxLinePaint(object sender, PaintEventArgs e) { using (var pen = new Pen(Color.Red, 1.5f)) { e.Graphics.DrawLine(pen, 10, 10, Width - 10, Height - 10); } } }这段代码的逻辑很直接:每次控件需要重绘时,系统触发 Paint 事件,e.Graphics是当前可用的绘制表面,DrawLine在这个表面上画一条从左上到右下的红线。using保证 Pen 被及时释放,避免在循环重绘时产生 GDI 句柄泄漏。
有一个细节要注意:Pen的宽度是 1.5f。如果你写的是Pen(Color.Red, 1),在默认坐标系下是一条物理像素宽的线;如果写0,则是 GDI+ 里特殊的 hairline,它表示“最细的线”,绘制方式和其他宽度不同。这个值会影响渲染速度,后面测速时建议固定。
2.3 不要在Paint之外随手CreateGraphics:闪烁和层覆盖的根源
很多人拿到这个标题后,第一件事就是拖一个 DX 控件到窗体上,然后写panel.CreateGraphics().DrawLine(...)。这样写通常也能画出线,但你会发现几个问题:窗口一刷新线就消失,拖动窗体时线会拖出残影,和 DX 自带皮肤叠加后还会出现明显的层级错乱。
原因在于CreateGraphics()拿到的是“临时绘制表面”,它不进入控件的持久化绘制流程。Windows 在窗口需要重绘时,只重发 WM_PAINT 消息,你之前用 CreateGraphics 画上去的内容不会被保留。正确的做法是把绘制指令放在 Paint 事件或OnPaint重写里,系统每次重绘都会重新执行,线才能稳定显示。
如果你只是想测速,不想让屏幕一闪一闪,那也不该用 CreateGraphics。更合理的方式是直接在内存 Bitmap 上画,这样能测出 GDI+ 指令本身的耗时,和屏幕刷新完全解耦。这个区分关系到后面测出来的数据到底代不代表真实性能,下面一章专门讲。
3. 把“画一条线”做成可靠基准:计时方式、循环次数和三个必调参数
3.1 先预热再计时,否则第一轮DrawLine慢得离谱
直接写一个循环,从Stopwatch.StartNew()开始,连续画一万次直线,然后算平均耗时。这个做法本身没问题,但有一个坑:第一轮绘制往往比后面慢很多。原因有三层:第一,DevExpress 程序集是懒加载的,第一次调用会触发程序集初始化;第二,GDI+ 第一次创建 Pen、Brush 时会加载内部缓存;第三,JIT 需要把绘制方法和循环体编译成机器码。
所以我的习惯是正式计时前先跑一段预热循环。预热结果不记录,只让绘制环境热起来。
using System; using System.Diagnostics; using System.Drawing; public class LineSpeedBench { private readonly Pen _pen = new Pen(Color.Black, 1f); public double Measure(int width, int height, int count) { using (var bmp = new Bitmap(width, height)) using (var g = Graphics.FromImage(bmp)) { // 预热:先画 100 次,不参与统计 for (int i = 0; i < 100; i++) g.DrawLine(_pen, 0, 0, width, height); // 正式计时 var sw = Stopwatch.StartNew(); for (int i = 0; i < count; i++) g.DrawLine(_pen, i % width, 0, width - i % width, height); sw.Stop(); return sw.Elapsed.TotalMilliseconds / count; } } }这里有几个参数值得说清楚。width和height决定绘制表面的大小,建议固定成你目标控件实际尺寸,比如 800x600。count是循环次数,不要设成 1,单次 DrawLine 的耗时通常在微秒级,直接测会受到计时器分辨率和线程调度干扰;我一般取 1000 或 10000,最后返回平均每线毫秒数。_pen是成员字段,只用创建一个实例,避免循环内反复创建。
还有一个容易被忽略的点:内存 Bitmap 默认不带透明度,且初始内容是未定义的。绘图前最好调用g.Clear(Color.White)刷一遍,否则第一次绘制结果可能叠加脏数据。虽然对耗时测量影响不大,但能保证你把测试代码改成截图保存时,输出图像是干净的。
3.2 离屏Bitmap与控件DrawToBitmap:两种测法测的不是一回事
用Graphics.FromImage(bmp)测的是纯 GDI+ 绘制指令耗时,它测不到 DX 控件自己那层主题绘制、缓存管理、坐标变换。换句话说,它只回答“C# 画一条线最低要多少时间”,回答不了“DX 控件上画一条线用户要等多久”。如果你的目标是对比不同控件或不同 DX 版本,就需要另一种方法:把真实控件渲染到 Bitmap 上。
using (var bmp = new Bitmap(panel.Width, panel.Height)) { panel.DrawToBitmap(bmp, new Rectangle(0, 0, panel.Width, panel.Height)); // 之后对 bmp 做像素读取或保存 }DrawToBitmap会向控件发送一条绘制消息,让控件把当前完整界面画到位图上,包括 DX 的皮肤、边框和你在 Paint 里画的内容。它的优点是接近真实显示效果,缺点也很明显:运行时间比单纯 DrawLine 长得多,因为它要把整个控件树递归绘制一遍;另外部分 DX 自定义绘制在 DrawToBitmap 路径下可能被跳过,所以结果要标注“通过 DrawToBitmap 采样”。
实际做性能验收时,我会把两者分开记录:一个叫GdipLineMs,一个叫DxPaintMs。前者用来判断 GDI+ 是否存在明显退化,后者用来判断 DX 控件的呈现链路是否变慢。两者结合,才能定位到是绘图指令本身慢,还是 DX 控件的外层处理拖了后腿。
3.3 SmoothingMode、Pen.Width和Clip区域:三个参数的敏感性
同样的 DrawLine,改一个参数,耗时可能翻倍。最容易踩的就是抗锯齿开关。Graphics.SmoothingMode默认是None,画出来的线边缘会有锯齿;一旦设置成AntiAlias,GDI+ 会对线段做边缘混合,涉及更多像素采样,斜线尤其明显。水平或垂直线受抗锯齿影响相对小,因为边缘对齐像素网格,所以如果你测的是斜线,抗锯齿带来的差异会非常大。
第二个参数是Pen.Width。常规宽度下,线越宽,光栅化阶段要填充的像素越多,耗时越高。但最特殊的是Width = 0f,它代表 hairline,会使用硬件支持的最小绘制宽度,绘制逻辑和普通宽度不一样,在某些显卡驱动上反而比1f更快。为了让测试结果可对比,我建议固定宽度,不要混用。
第三个是裁剪区域。e.Graphics.VisibleClipBounds表示当前可见的绘制范围。如果你的控件有一部分被遮挡,GDI+ 会自动裁剪掉不可见区域,实际绘制像素减少,耗时也会下降。这不是好事,因为你的业务代码还在跑,只是屏幕显示被省了。测速时要把控件完整显示出来,或者用全屏 Bitmap,避免裁剪目录干扰数据。
下面是我常用的参数参考表,目的是让同一项目的不同版本跑出来的数值可以直接对比。
| 参数 | 固定值 | 说明 |
|---|---|---|
| 绘制表面 | 内存Bitmap 或控件尺寸 | 不要一会儿宽一会儿窄 |
| 循环次数 | 1000 或 10000 | 取平均耗时 |
| 抗锯齿 | 明确固定 None 或 AntiAlias | 不能混跑 |
| Pen.Width | 1f 或 0f | 0f 是hairline,单独说明 |
| 直线方向 | 斜线/水平线分开测 | 抗锯齿对斜线影响更大 |
4. DX控件画线和测速:最常见的四个坑与排查思路
4.1 画好的线闪一下就没,或者被控件默认皮肤盖住
现象:你在 Paint 事件里画了一条粗线,运行后一闪而过,或者根本看不到,但窗口在调试时能命中断点。换成普通 UserControl 后,同样代码却正常。
原因:DX 的 PanelControl 不是普通面板,它内部有边框、背景和皮肤绘制层。某些主题下,控件先把皮肤画完,再触发你的 Paint,随后又有一次内部 Border 绘制覆盖在上面。类似的情况也出现在给 Panel 做圆角的时候,你会发现 Paint 里画的东西会被边框或背景吃晕。
解决:优先把画线内容放到一个普通 UserControl 上,外层再套 DX 控件;如果坚持用 PanelControl,就在OnPaint重写里绘制并调用base.OnPaint,不要只用事件。另外一个保险做法是把线画到图片上,再用控件的背景图属性显示,绕开绘制层级。顺带提一句,WinForms 控件属性大全里,UserControl 有一个受保护的 DoubleBuffered 属性,不少闪烁问题就是靠它解决的。
4.2 循环里new Pen和Brush,越画越慢
现象:测速脚本前几十次正常,后面肉眼可见变卡,任务管理器里 GDI 对象数不断上升,最后整窗白屏或绘制抛出OutOfMemoryException。
原因:Pen、Brush、Font 都属于 GDI 对象,每次new都占用一个句柄。循环里写new Pen(Color.Red)却不 Dispose,句柄很快被撑满。很多测速代码只注意了 Stopwatch,没注意对象释放,于是把“性能问题”测成了“内存泄漏问题”。
解决:在循环外创建、循环内复用,用完统一释放。上面的LineSpeedBench示例里我特意把_pen设为成员字段,就是这个原因。如果你需要多种颜色、多种宽度,也要预先建好对应的 Pen 集合,不要边画边 new。这条对生产代码比测速更重要,我见过不止一个报表模块因为在这里贪省事,上线几小时就句柄耗尽。
4.3 DPI从96切到120,坐标就不准了
现象:在 100% 缩放下线是准的,把 Windows 缩放改成 125% 或 150%,线明显偏移,或者整条线变粗变糊。截图比对时,绘制位置和预期坐标对不上。
原因:WinForms 的 PerMonitorV2 DPI 感知下,DX 控件内部会缩放字体和布局,但你自己用Width、Height做像素运算时,拿到的可能是逻辑像素。GDI+ 默认按物理像素绘制,二者一混合,坐标就错了。
解决:统一用e.Graphics.PageUnit = GraphicsUnit.Pixel并显式获取DeviceDpi。画线前先算一个缩放系数:
float scale = e.Graphics.DpiX / 96f; e.Graphics.DrawLine(_pen, 10f * scale, 10f * scale, Width - 10f * scale, Height - 10f * scale);如果你的项目已经开启了 DPI 感知,DX 控件内部会自动处理缩放,这种手动乘系数的方式反而可能造成二次缩放,要注意区分项目里有没有现成的ScaleFactor工具类。笔者的排查习惯是:先把窗体的AutoScaleMode固定为Dpi,画线坐标全部改成浮点数,再在不同缩放级别下截屏对比。
4.4 跨线程调用DrawLine直接崩溃
现象:后台线程或System.Timers.Timer里直接调用panel.CreateGraphics().DrawLine(...),程序时好时坏,偶尔抛InvalidOperationException,提示“线程间操作无效”。
原因:WinForms 控件只能在创建它的 UI 线程上操作,跨线程访问会被 .NET 的控件安全检查拦截。很多测速脚本为了不卡界面,会把绘制循环丢进Task.Run,结果撞上这个限制。
解决:把绘制指令切回 UI 线程。最轻量的写法是panel.BeginInvoke(new Action(() => ...)),但高频绘制下每次都 Invoke 仍然会积压消息。更稳的做法是后台线程只负责把要画的坐标写入线程安全队列,UI 线程在Application.Idle或定时器里统一取出并绘制。这套模型也适用于后面的批量画线场景。
5. 从一条线到批量矢量:什么时候该用DX控件,什么时候自绘
5.1 10万条线的场景:DrawLine不是为批量而生
把标题里的“画一条线”改成画一万条线,你会立刻发现效率瓶颈不在 DX 控件,而在 GDI+ 的立即绘制模式。每条DrawLine都是一次独立调用,调用开销和状态切换比光栅化更明显。常见做法是合并成一次DrawLines,把所有线条的端点放进一个PointF[]数组,让 GDI+ 一次提交。
using System.Drawing; public static void DrawManyLines(Graphics g, PointF[] points, Pen pen) { // points 数组长度为线段数的两倍,每一对表示一条线的起点和终点 g.DrawLines(pen, points); }这段代码的关键在points格式:它不是点的集合,是“线段的端点”集合,数组里每两个点组成一条线。如果你原来的逻辑是逐条判断颜色、宽度,那就不能合并,需要按相同绘制状态分段合并。DrawLines 只支持同一条 Pen 和同一种线型,想每根线不同颜色,要么拆开,要么用渐变画刷或逐段绘制,这一步省不掉。
当线条量继续涨到十万级以上,GDI+ 已经不适合作为主渲染路径。此时要么用 GraphicsPath 把线条预编译成路径再填充和描边,要么直接把绘制迁移到 DirectWrite 或 SkiaSharp。DX 控件在这里帮不了太多忙,因为它自身的绘制最终也走 GDI+。
5.2 DX自有绘制接口值得用的两个场合:单元格Tile和图表坐标面
如果你的“画线”不是画在空白面板上,而是要画进 GridControl 的单元格,或者 ChartControl 的坐标区域,思路就要换成 DX 的 CustomDraw 接口。拿 Grid 举例,单元格自定义绘制时要拿e.Cache.Graphics,而不是用CreateGraphics;绘制前先调用e.Appearance.FillRectangle填充背景,再画你的内容,否则单元格背景会被你的线盖住。
ChartControl 场景里,真正做部门常用的是 ConstantLine 或者 GridLine,这些是 DX 图表内置功能,不需要自绘。很多搜“C# dx 控件画线”的人,其实是想在图表上加一条均值线或阈值线,这时候自绘反而绕远路。优先查控件自带能力,确定没有,再回到 Paint 事件手绘。
5.3 什么时候放弃DX自绘:回到自定义UserControl
当你的绘制层需要平移、缩放、局部放大、动态更新坐标时,DX 控件那层皮肤和布局管线反而碍事。PictureBox 那一套重绘思路更合适:自己接管 Paint,自己管理一张离屏 Bitmap。做法是创建一个继承 UserControl 的类,把DoubleBuffered设为 true,每次交互只重新计算坐标并要求Invalidate()。
public class LineViewer : UserControl { public LineViewer() { DoubleBuffered = true; } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; e.Graphics.Clear(BackColor); // 这里有局部放大时,先取缩放矩阵,再画线 } }注意DoubleBuffered在 UserControl 里是受保护属性,你只有在子类里才能直接设置。如果是在窗体设计器里放一个普通 PictureBox,也可以把它的BackColor设成纯色,在 Paint 事件里全权接管。这个方案牺牲了 DX 的皮肤统一性,但换来了绘制逻辑的完全可控,遇到局部放大、坐标换算、橡皮筋框选这些需求时,改起来非常快。
6. 把测速结果留档:把一次手工测试变成可回归的验证
6.1 跑完测速后,把配置、结果和DX版本一起写进文本
性能测试最怕结果记在脑子里。今天改了一点代码觉得快了,下周改回来你根本不知道动了什么。我现在的做法是让测速函数直接返回一个字符串,包含测试时间、DX 版本、循环次数、抗锯齿开关和平均耗时,然后写进项目根目录下的bench.txt。
File.AppendAllText( "bench.txt", $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} | DX {typeof(PanelControl).Assembly.GetName().Version} | " + $"{count} lines | avg {avgMs:F3} ms");这套记录的意义不在于精准,而在于同一份脚本跑出的数字可以前后对比。改完绘图代码,跑一遍,看平均值是上升还是下降,比肉眼判断靠谱得多。如果你的项目已经接了 CI,也可以把它变成命令行工具,在每次提交后自动输出一组数值。
6.2 我的习惯:不做“肉眼快慢”判断
我自己在改 DX 绘制代码之前,都会先看一眼项目里的基准值有没有变化,而不是凭感觉说“好像变快了”。指标的名字就叫LineDrawMs/1000Lines,固定格式,固定循环次数。曾经有一次只是把 Pen 缓存起来就快了四倍,我说不清具体是哪一环省下来的,但基准数据帮我把结论定了下来。后来每次有人提议换渲染方案,我都让去跑一遍同款基准,数据说话。希望这个习惯对你也有用,也希望上面的参数和排查能帮你少走几步弯路。
本文还有配套的精品资源,点击获取