简介:一份面向C#开发者的DirectX直线绘制性能测试示例工程,围绕“C#测试DX画直线速度”主题,演示如何利用SharpDX/SlimDX封装库完成DirectX设备初始化、渲染目标设置、几何直线绘制,并通过渲染循环配合Stopwatch精确测量每次绘制的耗时,为入门DirectX图形编程、定位绘制效率瓶颈或学习性能打点方法的开发者提供了一条可直接运行的参考路径。包内共20个文件,体积仅38KB,属于轻量级示例,其中5个cs源码文件承载设备初始化与画线逻辑,3个exe可运行观察效果,sln与csproj负责工程组织,settings、resources、resx等文件提供配置与资源,pdb、cache用于调试与缓存,结构清晰,打开即可对照学习。已有140人浏览/学习这份示例工程,读者可重点结合断点观察执行时间,理解直线绘制的耗时来源,并通过窗体交互触发重绘,尝试调整顶点缓冲、着色器或绘制参数,进一步体会C#调用DirectX的流程与性能优化空间。
1. 用 C# 和 DX 控件画一条线,为什么值得单独测一下速度
早年做上位机,界面要显示实时曲线,第一反应就是 PictureBox 上自己画。画着画着就会发现:数据一多、刷新一快,界面就像被卡住喉咙,鼠标移过去都转圈。后来换用 DevExpress(老工程师习惯叫 DX)的控件,等于把渲染和交互的活交给了成熟框架,但**「快」不等于「不用管」**——DX 控件在 WinForms 里的布局引擎、绘制管线、数据绑定机制都有它自己的脾气,一条线画得慢,常常不是显卡不行,而是你没找到它正确的工作方式。
标题里这个「draw-a-line-speed」压缩包,本质是想验证一件事:在 C# WinForms 下用 DX 控件画线(或扩展成实时曲线、HMI 上的动态管道图),性能和资源占用到底处在什么水平。这篇文章就顺着这条线往下讲:DX 画线的底层机制是什么、最小可用代码怎么写、以及当数据量上来时,哪几个参数才是真正的性能闸门。你会看到可复制的代码、必调的属性,也会看到我踩过的几个坑——这些坑在官方 Demo 里通常不会主动告诉你。
适合谁?刚转 C# 上位机方向、在原生 GDI+ 和第三方控件库之间犹豫的新手,以及已经在用 DX 但总觉得界面「黏糊糊」的熟手。
2. DX 画线的性能瓶颈在哪:先把慢的原因找对
2.1 是你画得慢,还是控件本身慢
先说结论:DX 控件在 WinForms 下的性能问题,绝大多数不在绘制函数本身,而在三个地方——布局引擎重复计算、数据绑定触发的级联刷新、以及 GDI+ 与 DX 渲染管线的切换开销。
很多人第一次用 DX 画线,下意识是往窗体上拖一个 PictureBox,然后在 Paint 事件里调用 e.Graphics.DrawLine。这套在原生 GDI+ 里是常规操作,但放进 DX 容器里就变味了:DX 的每个控件都挂在它自己的可视树(Visual Tree)上,任何一次 Invalidate 都会引起父容器重新计算子控件位置和尺寸。你在 PictureBox 里每画一帧,DX 的布局引擎就跟着把整个窗体重新算一遍。数据量小的时候没感觉,每秒刷新 20 次、每次几百个点的时候,CPU 时间就大量消耗在「重新排版」而不是「画线」上。
另外,如果你是用 DX 的 GridControl 或者 ChartControl 来展示数据,绑定数据源时的级联刷新会更隐蔽。比如你把 List 集合直接塞给 DataSource,然后每来一条新数据就往集合里 Add——这会让控件认为整个数据源都变了,触发全量重建行、重算列宽、重新绑定单元格。画一条线看似简单,背后其实拖了一整辆火车。
2.2 DataFrame 还是 GDI+ 画布:两条路线怎么选
DX 体系里画线,常见路线有两条,性能特征完全不同:
路线 A:用 DX 的 ChartControl 画实时曲线。这条路的优势是坐标轴、缩放、十字光标都现成,适合做温度曲线、压力趋势这类带坐标语义的数据展示。性能上,ChartControl 有自己的数据点缓存机制,只要你不频繁替换整个 Series 对象,只是往里追加点,渲染效率是能接受的。问题在于:ChartControl 的 SeriesPoint 每次 Add 都会触发内部重绘,点多了(比如超过 5 万点)照样卡。
路线 B:用 DX 的 PanelControl 或直接自定义控件,在 Paint 事件里自己画线。这条路适合做无坐标轴的图形——工艺流程管道、设备连线、简单的向量示意。DX 对自定义绘制的支持有一个关键接口:BaseControl下的Paint事件,配合Cache对象(DevExpress 的图形缓存机制),可以做到比 GDI+ 更高效的离屏绘制。
我一般的做法是:有坐标轴就用 ChartControl,没坐标轴就用自绘。但自绘有个前提:必须用 DX 的GraphicsCache,而不是直接用e.Graphics裸画。原因在于 GraphicsCache 会把绘制指令缓存成批处理,避免 GDI+ 和 DX 渲染上下文之间反复切换。这个切换是很贵的——每切换一次,CPU 就要做一次上下文保存和恢复,画 1000 条线就是 1000 次切换。
2.3 关键参数:为什么说 DX 的性能藏在「禁用」里
DX 控件性能调优的核心逻辑不是「开更多功能」,而是「关掉用不上的功能」。下面这几个属性是画线场景里最影响速度的开关,我把它们的效果列成一张表:
| 属性 | 位置 | 默认值 | 画线场景的建议 | 为什么 |
|---|---|---|---|---|
AllowUpdateAnimation | 容器控件 | 开启 | 关闭 | 动画会让每次数据更新都插值过渡,高频刷新时纯属浪费 |
AllowHtmlDraw | 控件外观 | 开启 | 关闭 | HTML 绘制模式会启用更重的渲染管线,单纯画线用不上 |
AllowGlyphSkinning | 控件外观 | 开启 | 关闭 | 按钮图标的着色重绘,对线条绘制无贡献 |
LookAndFeel.UseDefaultLookAndFeel | 全局 | true | false(配合手动指定) | 关闭默认外观查找,避免每次绘制都去翻皮肤资源表 |
Cache | 自绘控件 | — | 必须使用 | 批处理绘制指令,见 4.2 节代码 |
还有一个容易被忽视的:DX 控件的AutoSizeMode。如果你把 PanelControl 的 AutoSizeMode 设为AutoSize,那么每次内容变化都会触发尺寸重新计算。画线时线条长度是动态的,这会让控件频繁重算布局。改成Free或固定尺寸,能立刻省掉一截 CPU 开销。
注意:DX 的不少「默认开启」都是为业务型界面设计的——数据表格、按钮、菜单需要这些效果。但在画线这种高频刷新场景,它们全成了负担,关掉才能看到真正的性能水平。
3. 用 DX 控件在本地跑通最小画线工程:从建项目到画出第一条线
3.1 建项目、引包、设置环境:三件事按顺序做
先用 Visual Studio 建一个 .NET Framework 4.7.2 或 .NET 6 的 WinForms 项目(DX 对 .NET 6 以上支持完整,但老项目的 Framework 版本也能跑)。然后通过 NuGet 安装 DevExpress.WinForms 包,安装完会自动引用 DX 的一堆程序集。要确认DevExpress.Drawing、DevExpress.Utils、DevExpress.XtraEditors这三个命名空间能被识别到——画线核心就是它们。
接着新建一个窗体,拖一个PanelControl上去,Dock 设为 Fill。这一步不要直接拖 PictureBox,因为 PanelControl 是 DX 的容器基类,它的 Paint 事件支持 GraphicsCache(PictureBox 不支持)。
最后,在窗体的构造函数里把上一节说的全局外观和动画关掉:
public partial class MainForm : Form { public MainForm() { InitializeComponent(); // 关闭DX全局动画和皮肤查找,降低高频刷新时的额外开销 DevExpress.XtraEditors.WindowsFormsSettings.AllowUpdateAnimation = DevExpress.Utils.DefaultBoolean.False; DevExpress.XtraEditors.WindowsFormsSettings.AllowHtmlDraw = DevExpress.Utils.DefaultBoolean.False; DevExpress.LookAndFeel.UserLookAndFeel.Default.Style = DevExpress.LookAndFeel.ActiveLookAndFeelStyle.Flat; } }这段代码里,AllowUpdateAnimation和AllowHtmlDraw都设成DefaultBoolean.False,等于从全局把 DX 的动画渲染和 HTML 绘制模式关掉。ActiveLookAndFeelStyle.Flat是扁平外观,比默认的 3D 风格少一层阴影计算,画线场景足够用。
提示:
WindowsFormsSettings的赋值最好放在InitializeComponent()之后,否则窗体上的控件在初始化时仍然会按默认设置走一遍。
3.2 在 PanelControl 的 Paint 事件里画一条线:最小代码
画线的核心代码写在 PanelControl 的Paint事件里。这里要注意:DX 控件的事件签名是PaintEventArgs,它的Graphics属性类型是DevExpress.Drawing.DXGraphics,而不是原生System.Drawing.Graphics。如果你直接用e.Graphics.DrawLine,语法上没问题,但画出来的线不会经过 DX 的渲染管线,性能提升无从谈起。
正确做法是先通过GraphicsCache拿到绘制对象,再画:
private void panel1_Paint(object sender, PaintEventArgs e) { // 建立图形缓存,DX会把绘制指令批处理后再提交 using (GraphicsCache cache = new GraphicsCache(e.Graphics)) { // 定义一个画笔:2像素宽、深蓝色 using (DXPen pen = new DXPen(Color.FromArgb(30, 80, 180), 2f)) { // 从左上到右下画一条对角线 cache.DrawLine(pen, new Point(10, 10), new Point(panel1.Width - 10, panel1.Height - 10)); } } }逻辑说明:GraphicsCache是 DX 提供的图形缓存对象,它内部维护了一个命令列表,会在Dispose时统一把命令交给底层渲染引擎,等效于把多次 GDI+ 调用合并成一次批量操作。DXPen是 DX 的画笔类型,对应原生 Pen,但由 DX 统一管理资源,用完Dispose,避免 GDI+ 对象泄漏。DrawLine 的参数就是起点和终点,坐标可以是Point或PointF,后者对应亚像素精度,画平滑曲线时有用。
3.3 这行代码跑起来的现象和验证:怎么确认它真的走了 DX 管线
按下 F5 运行,窗口里应该能看到一条从左下到右上的斜线。这时候如果你用 DX 自带的DXTrace工具(在 DX 安装目录的 Tools 下,或者 NuGet 包 DevExpress.Debugger 里),可以看到绘制调用被合并的记录——这是确认走没走 GraphicsCache 的最直接证据。
如果线没显示出来,优先查三件事:第一,PanelControl 的BackgroundColor是否和线的颜色太接近;第二,Paint事件是否真的被订阅了(在窗体设计器里双击 PanelControl 会自动订阅,但如果你手写代码添加,要确认+=没漏);第三,cache.DrawLine里的坐标是否落在控件的可视范围内——比如 PanelControl 高度为 0 或者被其他控件盖住,线画了也看不见。
验证性能的简单办法是用System.Diagnostics.Stopwatch包住 Paint 事件里的绘制代码,记录每次绘制耗时。初次跑通时这个值会有波动,说明系统还在加载 DX 的程序集和字体缓存,多跑几次后应该稳定在 1 毫秒以内。如果持续超过 5 毫秒,基本可以断定你的配置有问题——多半是动画或外观相关的开关没关干净。
4. 让「一条线」变成「几千条线」:DX 画线的性能压测与调参
4.1 数据准备:先构造一个能压出性能问题的数据集
画一条线验证不了什么问题,得把数据量推上去。真实的上位机场景里,一条实时曲线每一秒来 50~100 个点,一个班次 8 小时就是上百万个点。我们这里构造一个相对温和但足以暴露性能问题的测试集:一次性生成 50,000 个点,模拟一段连续的实时数据。
private List<double> GenerateTestData(int count) { var data = new List<double>(count); var rand = new Random(42); // 固定种子,保证每次跑出来的数据一致 double value = 100; for (int i = 0; i < count; i++) { // 模拟一个有趋势叠加噪声的传感信号 value += (rand.NextDouble() - 0.5) * 4; data.Add(value); } return data; }List<double>初始容量直接设为count,避免 Add 过程中反复扩容——扩容涉及数组复制,5 万个点逐次扩容会白白消耗时间。固定随机种子是为了控制变量:你调完参数后对比帧率时,数据是一样的,结果才可比。
4.2 绘制 5 万个点的两种写法:直观写法为何必慢
先看直观写法——用循环一条条画线段:
private void panel1_Paint(object sender, PaintEventArgs e) { using (GraphicsCache cache = new GraphicsCache(e.Graphics)) { using (DXPen pen = new DXPen(Color.FromArgb(30, 80, 180), 1f)) { // 这样画,5万点就会产生5万次绘制指令 for (int i = 1; i < points.Count; i++) { cache.DrawLine(pen, new PointF(i - 1, (float)points[i - 1]), new PointF(i, (float)points[i])); } } } }这段代码逻辑上是正确的,但性能很糟糕。原因有两层:第一,每次DrawLine都是一次独立的绘制指令,5 万次调用就有 5 万次指令进入管线,GraphicsCache 的批处理优势被逐条调用的行为完全抵消;第二,PointF的坐标是每次循环现算的,CPU 在循环里反复做类型转换和装箱操作,进一步加剧开销。
正确的做法是把点集封装成DXPointF[]数组,用DrawPolyline一次性提交:
private void panel1_Paint(object sender, PaintEventArgs e) { using (GraphicsCache cache = new GraphicsCache(e.Graphics)) { using (DXPen pen = new DXPen(Color.FromArgb(30, 80, 180), 1f)) { // 把点集转成DX认识的数组类型,一次性绘制 DXPointF[] dxPoints = new DXPointF[points.Count]; for (int i = 0; i < points.Count; i++) { dxPoints[i] = new DXPointF(i, (float)points[i]); } cache.DrawPolyline(pen, dxPoints); } } }DrawPolyline接收的是DXPointF[],它会把这个数组整体提交给渲染管线,由底层一次性完成折线的光栅化。对比两种写法,后者在海量点场景下通常快 3 到 10 倍——具体倍数取决于点数和 CPU 架构。一个附带的好处是:DrawPolyline支持线段级抗锯齿,DX 会在渲染管线内部处理,不需要你每段线单独设置。
注意:
DrawPolyline不适合点数超过 100 万的情况。超过这个量级,建议改用DevExpress.XtraCharts的SeriesViewBase配合数据缩减策略(比如抽稀),而不是继续压像素级绘制。
4.3 翻车现场:数据绑定与布局刷新才是最大的隐藏成本
很多人在用 DX 画线时遇到的真正翻车现场不是绘制太慢,而是「绘制不慢,但整个界面卡住」。给一个我自己调过的典型案例:
现象:PanelControl 的 Paint 事件里画线耗时只有 2 毫秒,但只要数据一刷新,CPU 飙到 30%,界面拖动都费劲。
原因:在 Paint 事件之外,有代码不断修改 PanelControl 的Text属性或Size属性。DX 的容器控件每次属性变化都会触发 Invalidate——不只是重绘自身,还会通知父容器重排子控件。你的绘制本身很快,但它被一次次的「重排」拖着反复执行。
解决:把频繁变化的文本和动态尺寸从 PanelControl 上移走。文本用独立 Label 显示,尺寸在构造时固定死。PaneControl 只做一件事:承载绘制区域。这也是为什么很多 DX 上位机工程里,曲线区域用的是ChartControl而不是自绘 Panel——ChartControl 把数据更新和布局重排做了隔离,你更新数据点时它不会去碰布局系统。
真正的性能压测要分两层看:一层是纯绘制耗时(Paint 事件里的 Stopwatch),另一层是整体交互帧率(用Application.Idle事件配合计时器统计)。很多优化做完后第一层明显下降,第二层没变化——这说明瓶颈根本不在画线,而在控件布局上。
我用一个高频刷新场景做基准:用一个System.Windows.Forms.Timer每 20 毫秒触发一次panel1.Invalidate(),然后在外部用 Stopwatch 统计 1 秒内实际上完成了多少次 Paint。性能调优前后的对比很直观:
| 调优阶段 | 每帧绘制耗时 | 每秒实际刷新帧数 | CPU 占用 |
|---|---|---|---|
| 初始状态 | 35 ms | 约 12 帧(被布局刷新拖累) | 25% |
| 关闭动画 + 固定尺寸 | 8 ms | 约 45 帧 | 15% |
| DrawPolyline 批量绘制 | 2 ms | 约 60 帧(受限于屏幕刷新率) | 8% |
这个表的含义是:只优化绘制代码不够,布局和动画的开销往往更大。真正的高频刷新方案,最终往往要控制刷新频率——不是每来一个数据点就刷新一次界面,而是攒一批(比如 20 毫秒攒 20 个点)再一次性刷新,让帧率稳定在 30~60 FPS。
4.4 显卡加速、DPI 感知和坐标预换算:三个容易被忽略的边界
DX 在 WinForms 下的绘制默认走 CPU,不走显卡。这意味着高 DPI 屏幕上,同样一条线会占用更多像素,绘制耗时变长。如果你的目标跑分环境是高 DPI 显示器(比如 4K 屏 150% 缩放),有两个必做的事:
第一,在 Program.cs 的 Main 方法里提前声明 DPI 感知:
// 放在 Application.Run 之前 DevExpress.XtraEditors.WindowsFormsSettings.SetPerMonitorDpiAware(); DevExpress.XtraEditors.WindowsFormsSettings.SetDPIAwareness( DevExpress.XtraEditors.WindowsFormsSettings.DPIAwareness.System);第二,把坐标预换算到物理像素。DX 的GraphicsCache有一个DpiScale属性,它表示当前控件在 X 和 Y 方向上的缩放系数。你可以在构造点数组时直接把坐标乘上这个系数,避免在绘制管线内部做逐点缩放。预换算看起来只是把乘法提前了,但在 5 万点场景下能节省一笔不小的重复计算:
DXPointF[] dxPoints = new DXPointF[points.Count]; float scaleX = cache.DpiScale.X; float scaleY = cache.DpiScale.Y; for (int i = 0; i < points.Count; i++) { dxPoints[i] = new DXPointF(i * scaleX, (float)points[i] * scaleY); }5. 避坑提醒:DX 画线常见的 5 个坑,按「现象 → 原因 → 解决」写给你
5.1 画出来的线有锯齿,但开启抗锯齿后性能暴跌
现象:直线边缘有毛刺,开启cache.SmoothingMode = DXAntiAliasingMode.AntiAlias后线条变平滑,但每帧绘制耗时从 2 毫秒涨到 15 毫秒。
原因:DX 的抗锯齿是按像素进行的,每个绘制像素都要做覆盖面积计算。5 万点的折线在抗锯齿模式下,计算量不是线性增长,而是接近倍数增长。
解决:抗锯齿和点数要平衡。点数少(低于 1 万)时开启没问题;点数多时关掉抗锯齿,改用颜色来弱化锯齿感——比如线的颜色比背景稍深一点、线宽加到 2 像素,视觉上就没有明显的锯齿了。实在需要抗锯齿,用 DrawPolyline 一次提交的方案,因为它的抗锯齿在管线内做批处理,比逐段 DrawLine 快得多。
5.2 修改了 DataSource 后刷新卡顿 1 秒以上
现象:把新的 List 赋给 ChartControl 的 DataSource,界面卡住 1~2 秒,然后才显示新曲线。
原因:每次给 DataSource 赋新对象,DX 会执行完整的Reset流程——释放旧的绑定、重建列、重新计算自动范围。这个操作对大数据集特别昂贵。
解决:用Series.BeginUpdate()和Series.EndUpdate()包住数据更新操作。BeginUpdate 会暂停控件的布局和绘制,EndUpdate 时才一次性恢复。这个机制类似 WinForms 控件的SuspendLayout和ResumeLayout,但作用在数据层,能避免更新途中的反复重绘:
series.BeginUpdate(); try { // 清空旧点、加入新点 series.Points.Clear(); for (int i = 0; i < newData.Count; i++) { series.Points.Add(new SeriesPoint(i, newData[i])); } } finally { series.EndUpdate(); }5.3 界面最小化后再恢复,线条消失或错乱
现象:窗口最小化再恢复,PanelControl 上的线不见了,或者位置偏移到错误的地方。
原因:窗口最小化时控件尺寸可能会重置为 0,恢复后 Paint 事件触发时,你代码里的坐标计算用的是旧的panel1.Width和panel1.Height,如果这些值是最小化时的 0,画出来的线自然不可见。另外,某些 DX 控件在最小化时会释放渲染资源,恢复后需要重新初始化。
解决:在 PanelControl 的SizeChanged事件里加一个标志位,标记尺寸已变化,在 Paint 事件里检查并重新计算坐标。不要把坐标计算直接写在 Paint 里——Paint 的触发频率比 SizeChanged 高得多,每次重算既有性能浪费,又有逻辑隐患。
5.4 启用双缓冲后反而变卡
现象:为了防闪烁,设置了DoubleBuffered = true,结果帧率反而下降。
原因:DX 控件内部已经做了自己的缓冲机制。你再用 WinForms 的标准双缓冲,等于画两遍——先是 DX 画到自己的缓冲,再被 WinForms 双缓冲复制一遍,多了一次全屏位图拷贝。
解决:使用 DX 控件时不要设置DoubleBuffered。DX 默认的缓冲策略已经能防止闪烁。实在有闪烁问题,优先检查是否混用了原生控件(比如 PanelControl 里嵌套了一个原生 PictureBox)——混用才是闪烁的根源,双缓冲是错误解法。
5.5 用 Timer 高频刷新时,CPU 占用居高不下
现象:Timer 间隔设 10 毫秒刷新一次曲线,CPU 占用一直维持在 30% 以上。
原因:WinForms 的 Timer 精度不高,而且每次触发都会无条件刷新界面。你明明只需要每秒 50 帧,但 Timer 实际触发频率可能超过 100 次/秒,加上每次刷新都完整重绘整条曲线,CPU 当然吃不消。
解决:断开 Timer 和绘制的直接联系。用数据累计的方式,每隔固定时间检查「是否有点到达」,攒够一批再刷新。同时按实际需求设置最小刷新间隔——显示实时数据 30 FPS 已是人眼极限,不需要 100 FPS。实测把刷新频率从 10 毫秒改为 30 毫秒,CPU 占用通常能下降一半以上,观感几乎无差别。
6. 如何验证你的 DX 画线方案真的变快了:一个朴素但有效的基准方法
性能这种事,不拿数据说话都是玄学。我惯用一个很朴素的基准方法。先说做法,再说判断标准。
在窗体上放一个 Label,用一个定时器每秒读一次平均帧率——帧率的统计方式是:记录一秒钟内 Paint 事件实际被调用的次数。这个数字比 Stopwatch 测单次绘制耗时更能反映真实体验,因为它把布局刷新、数据绑定、控件重排的代价全部算进去了:
// 在Paint事件中累加计数 int paintCount = 0; private void panel1_Paint(object sender, PaintEventArgs e) { paintCount++; // 实际绘制代码 DrawFrame(e.Graphics); } // 每秒计算一次帧率 private void timer1_Tick(object sender, EventArgs e) { label1.Text = $"{paintCount} FPS"; paintCount = 0; }当我把这个方法和压力数据配合使用时,发现两个经验值:第一,画实时曲线的推荐 FPS 区间是 30~45 帧,低于 20 帧会明显感觉拖影,高于 60 帧则没有实际收益,只是白白占用 CPU——屏幕刷新率上限决定了你根本看不见更多帧。第二,CPU 占用率比 FPS 更能暴露问题。同样 30 FPS,占用 8% 和占用 25% 背后是两种完全不同的实现质量。占用高的方案改用BeginUpdate批处理或DrawPolyline批量绘制后,通常能压到原来的三分之一。
我个人做 DX 画线性能调优的习惯是:只调一个变量,测一次数据。关掉动画、改完绘制方式、调整完刷新频率,每一步都要单独跑一轮基准。把它们混在一次改完,性能确实上去了,但你不知道究竟是哪一步起的作用——下次在别的机器上翻车时,连排查方向都没有。
回头看标题里那个压缩包里的内容:它大概率就是把「画一条线」这件事做到不同的控件方案下,比较各自的耗时和资源占用。这个思路本身值得借鉴——不要相信任何人告诉你的性能结论,包括我写的。在自己的机器上、用自己的数据集、跑出自己的帧率曲线,才是靠谱的决策依据。
希望这个方法能帮你把 DX 画线的性能问题看得更清楚,少走几步我没能绕开的弯路。
本文还有配套的精品资源,点击获取