大家好,最近我挖了一个新的开源项目坑:N-Body 模拟,这是一个纯粹由兴趣驱动的项目,旨在通过编程模拟天体间的万有引力,并欣赏由物理规律所生成的优美图形。
在这个项目中,有一个核心环节是绘制天体的运行轨迹。轨迹本质上是一条由无数个点连接而成的曲线。为了高效存储这些点,我使用了一个 CircularBuffer<T>
,即环形缓冲区。它的内部实现相当经典:一个数组加上两个指针,分别标记数据的有效起止位置,非常适合存储这种定长的流式数据。

初遇瓶颈:当轨迹长到令人抓狂
最初,我选择使用 Direct2D 的 DrawLine
方法来逐段绘制轨迹。代码的逻辑非常直观,就是遍历轨迹点,然后两两相连画线:
csharp
for (int i = 0; i < _lastSnapshot.Stars.Length; ++i)
{
StarSnapshot star = _lastSnapshot.Stars[i];
StarUIProps prop = _uiProps[i];
// 遍历每两个相邻的点,并绘制一条线段
prop.TrackingHistory.Enumerate2((Vector2 from, Vector2 to, int i) =>
{
// 根据点的位置计算一个渐变透明度
float alpha = 1.0f * i / (prop.TrackingHistory.Count - 1);
Color4 color = new Color4(prop.Color.R, prop.Color.G, prop.Color.B, alpha);
// 调用DrawLine API
ctx.DrawLine(from, to, XResource.GetColor(color), 0.02f);
});
}
在轨迹点不多的时候,这套方案跑得非常欢快。然而,当用户希望看到更长、更华丽的轨迹时,问题就暴露了。当点的数量达到 10万 个级别时,界面开始出现肉眼可见的卡顿和掉帧。很显然,性能瓶颈出现了,优化迫在眉睫。
量化问题:用数据说话
为了精准定位问题,我进行了一次简单的性能测试。我使用 Stopwatch
来记录在轨迹点数达到10万个时,整个绘制过程的耗时。
csharp
protected override void OnDraw(ID2D1DeviceContext ctx)
{
// ... 其他绘制准备工作 ...
Stopwatch sw = Stopwatch.StartNew();
DrawCore(ctx); // 核心绘制逻辑
// 当轨迹点达到10万时,打印耗时
if (_uiProps[0].TrackingHistory.Count == 100000)
{
sw.Elapsed.TotalMilliseconds.Dump();
}
// ... 其他效果处理 ...
}
测试结果相当不乐观,连续几次的耗时输出如下:
50.0262
51.7592
51.0839
50.7521
50.838
平均耗时稳定在 50毫秒 左右!这是一个什么概念?为了保证流畅的用户体验(比如 60 FPS),每一帧的渲染时间必须控制在 16.67毫秒 以内。现在 50 毫秒的耗时,意味着帧率已经掉到了 20 FPS 以下,卡顿是必然的结果。
柳暗花明:一次调用胜过十万次
既然 DrawLine
的循环调用是瓶颈,那么优化的思路就应该是减少调用的次数 。在和朋友讨论后,我决定尝试使用 ID2D1PathGeometry
来重构绘制逻辑。
ID2D1PathGeometry
允许我们先在内存中构建一个完整的几何路径,然后一次性地将其提交给 GPU 进行绘制。新的代码如下:
csharp
// 先绘制轨迹
for (int i = 0; i < _lastSnapshot.Stars.Length; ++i)
{
StarSnapshot star = _lastSnapshot.Stars[i];
StarUIProps prop = _uiProps[i];
if (prop.TrackingHistory.Count < 2) continue;
// 1. 创建一个路径几何对象
using ID2D1PathGeometry1 path = XResource.Direct2DFactory.CreatePathGeometry();
// 2. 打开路径并获取一个"画笔" (GeometrySink)
using ID2D1GeometrySink sink = path.Open();
// 3. 定义路径的起点
sink.BeginFigure(prop.TrackingHistory.First!.Value, FigureBegin.Hollow);
// 4. 将所有的点批量添加到路径中
prop.TrackingHistory.Enumerate((pt, index) =>
{
if (index > 0) { sink.AddLine(pt); }
});
// 5. 结束并关闭路径定义
sink.EndFigure(FigureEnd.Open);
sink.Close();
// 6. 一次性将整个路径绘制出来
ctx.DrawGeometry(path, XResource.GetColor(prop.Color), 0.02f);
}
改完代码后,我怀着忐忑的心情再次运行性能测试,结果让我大吃一惊:
6.8739
6.4511
6.436
6.0901
5.9227
平均耗时骤降到了 6毫秒 左右!性能几乎提升了 10倍!🚀
刨根问底:为什么"更重"的代码跑得更快?
这个结果一度让我非常困惑。从代码表面上看,使用 ID2D1PathGeometry
的版本涉及到了更多的 API 调用:CreatePathGeometry
、Open
、BeginFigure
、AddLine
、EndFigure
、Close
,还有多个 using
语句。这套操作看起来比一个简单的 DrawLine
调用要"重"得多。
我曾经误以为,DrawLine
是一个非常底层的、直接的绘制指令,而 ID2D1PathGeometry
是一个更上层、更抽象的封装,性能可能会更差。
但真正的关键在于理解 Draw Call
(绘制调用)的成本。
每一次 ctx.DrawLine
的调用,都是一次 CPU 到 GPU 的通信 ,我们称之为 Draw Call
。这是一个相对昂贵的操作,因为它涉及到状态切换、数据传输和驱动程序开销。在我最初的实现中,绘制10万个点的轨迹,就意味着产生了 10万次 Draw Call
!
而使用 ID2D1PathGeometry
的方案,虽然在 CPU 端看起来代码更复杂,但所有的路径构建工作(AddLine
等)都只在内存中进行,不涉及与 GPU 的直接交互 。直到最后调用 ctx.DrawGeometry
时,这 10 万个点的几何数据才被打包好,一次性地提交给 GPU。
这就相当于,我们将 10万次零散的 Draw Call
合并成了一次重量级的 Draw Call
。GPU 一次性接收所有数据,然后高效地完成光栅化。虽然单次传输的数据量变大了,但完全避免了 99999 次昂贵的通信开销。这正是性能提升近10倍的根本原因。
总结
这次优化经历让我深刻体会到,在性能优化的世界里,找到瓶颈所在,远比知道如何优化更重要。
- 表象具有欺骗性:API 调用的多寡并不直接等同于性能开销。看起来"重"的代码,可能因为更符合底层硬件的工作原理而快如闪电。
- 理解原理是关键 :如果不理解
Draw Call
的成本,我可能会在其他地方(比如数据存储、颜色计算)浪费大量时间,而这些地方的优化对于整体性能来说可能只是杯水车薪。只有理解了"CPU与GPU通信是昂贵的"这一原理,才能找到正确的优化方向。 - 量化驱动优化 :没有性能测试的数据支撑,所有的优化都只是猜测。通过
Stopwatch
精准量化,我们能清晰地看到优化的效果,并确认我们的方向是正确的。
性能问题往往不是因为计算机"算得慢",而是因为我们在用一种"低效的方式"让它去算。理解其工作原理,才能让它发挥出真正的威力。
感谢您阅读到这里!如果感觉本文对您有帮助,请不吝 评论 和 点赞,这也是我持续创作的最大动力!
也欢迎加入我的 .NET骚操作 QQ群:495782587,一起交流 .NET 和 AI 的各种有趣玩法!