大家好,最近我挖了一个新的开源项目坑:N-Body 模拟,这是一个纯粹由兴趣驱动的项目,旨在通过编程模拟天体间的万有引力,并欣赏由物理规律所生成的优美图形。

在这个项目中,有一个核心环节是绘制天体的运行轨迹。轨迹本质上是一条由无数个点连接而成的曲线。为了高效存储这些点,我使用了一个 CircularBuffer<T>,即环形缓冲区。它的内部实现相当经典:一个数组加上两个指针,分别标记数据的有效起止位置,非常适合存储这种定长的流式数据。


初遇瓶颈:当轨迹长到令人抓狂

最初,我选择使用 Direct2D 的 DrawLine 方法来逐段绘制轨迹。代码的逻辑非常直观,就是遍历轨迹点,然后两两相连画线:

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万个时,整个绘制过程的耗时。

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 进行绘制。新的代码如下:

// 先绘制轨迹
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 调用:CreatePathGeometryOpenBeginFigureAddLineEndFigureClose,还有多个 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倍的根本原因。


总结

这次优化经历让我深刻体会到,在性能优化的世界里,找到瓶颈所在,远比知道如何优化更重要

  1. 表象具有欺骗性:API 调用的多寡并不直接等同于性能开销。看起来“重”的代码,可能因为更符合底层硬件的工作原理而快如闪电。
  2. 理解原理是关键:如果不理解 Draw Call 的成本,我可能会在其他地方(比如数据存储、颜色计算)浪费大量时间,而这些地方的优化对于整体性能来说可能只是杯水车薪。只有理解了“CPU与GPU通信是昂贵的”这一原理,才能找到正确的优化方向。
  3. 量化驱动优化:没有性能测试的数据支撑,所有的优化都只是猜测。通过 Stopwatch 精准量化,我们能清晰地看到优化的效果,并确认我们的方向是正确的。

性能问题往往不是因为计算机“算得慢”,而是因为我们在用一种“低效的方式”让它去算。理解其工作原理,才能让它发挥出真正的威力。


感谢您阅读到这里!如果感觉本文对您有帮助,请不吝 评论点赞,这也是我持续创作的最大动力!

也欢迎加入我的 .NET骚操作 QQ群:495782587,一起交流 .NET 和 AI 的各种有趣玩法!

更复杂的代码,为何跑得快了10倍?一次Draw Call优化引发的思考的更多相关文章

  1. 编写更少量的代码:使用apache commons工具类库

    Commons-configuration   Commons-FileUpload   Commons DbUtils   Commons BeanUtils  Commons CLI  Commo ...

  2. 面试 6:拓展性更好的代码,更容易拿到 Offer

      今天给大家带来的是 <剑指 Offer>习题:调整数组顺序使奇数位于偶数前面,纯 Java 实现希望大家多加思考. 面试题:输入一个整型数组,实现一个函数来调整该数组中的数字的顺序,使 ...

  3. 面试官:如何写出让 CPU 跑得更快的代码?

    前言 代码都是由 CPU 跑起来的,我们代码写的好与坏就决定了 CPU 的执行效率,特别是在编写计算密集型的程序,更要注重 CPU 的执行效率,否则将会大大影响系统性能. CPU 内部嵌入了 CPU ...

  4. git 常用命令,上传,下载,更新线上代码

    git 常用命令以及推荐git新建上传个人博客 $ git clone  //本地如果无远程代码,先做这步,不然就忽略 $ git status //查看本地自己修改了多少文件 $ git add . ...

  5. 通过利用immutability的能力编写更安全和更整洁的代码

    通过利用immutability的能力编写更安全和更整洁的代码 原文:Write safer and cleaner code by leveraging the power of "Imm ...

  6. VS Code Java 更新 – 全新Gradle for Java插件,更方便的代码操作, 1.0 语言支持发布

    大家好,欢迎来到 9 月版的 Visual Studio Code Java 更新.在这篇文章中,我们将分享我们最新的Gradle插件,更加方便的代码操作(Getter/Setter等等),以及最近的 ...

  7. 性能测试记录: ZZ 只改5行代码获得10倍吞吐量提升

    首先得找台足够性能的机器来测试,性能不足时代码运行会出现各种奇怪的现象,导致浪费时间 文章: https://www.jianshu.com/p/4cd8596352ad   只改了5行代码吞吐量提升 ...

  8. 程序员需要经纪人吗?10x 最好的程序员其生产力相当于同行的 10 倍~

    原文地址 10x 起源于技术界一个流行的说法,即最好的程序员是超级明星,其生产力相当于同行的 10 倍: Google 园区以好玩的设施闻名:小憩舱.球坑.按摩.干洗.随便吃到饱的自助餐.(为了拍人才 ...

  9. Web 应用性能提升 10 倍的 10 个建议

    转载自http://blog.jobbole.com/94962/ 提升 Web 应用的性能变得越来越重要.线上经济活动的份额持续增长,当前发达世界中 5 % 的经济发生在互联网上(查看下面资源的统计 ...

  10. 使用Apache Spark 对 mysql 调优 查询速度提升10倍以上

    在这篇文章中我们将讨论如何利用 Apache Spark 来提升 MySQL 的查询性能. 介绍 在我的前一篇文章Apache Spark with MySQL 中介绍了如何利用 Apache Spa ...

随机推荐

  1. Qt图像处理技术六:拉普拉斯锐化

    Qt图像处理技术六:拉普拉斯锐化 效果图 源码 由该公式得到下方卷积核 使用到的卷积核: //都把QImage转化为rgb888更好运算 QImage LaplaceSharpen(const QIm ...

  2. FileChooser文件保存样例

    FileChooser fc = new FileChooser();fc.setTitle("请选择文件保存位置");fc.setInitialDirectory($原始文件位置 ...

  3. 把多个文件打包压缩成tar.gz文件并解压的Java实现

    压缩文件   在Java中,可以 使用GZIPOutputStream创建gzip(gz)压缩文件,它在commons-compress下面,可以通过如下的maven坐标引入: <depende ...

  4. TINYINT[M]、INT[M]和BIGINT[M]中M值的意义

    TINYINT[(M)] [UNSIGNED] [ZEROFILL] A very small integer. The signed range is -128 to 127. The unsign ...

  5. 【UEFI】HOB 从概念到代码

    总述 使用 HOB 的原因是因为,在 PEI 阶段内存尚未完全初始化,到了 DXE 阶段才完整初始化了内存,所以无法通过简单的内存地址传递数据,并且我们仍然有一些对于内存空间存储的需求,因此发明了 H ...

  6. 几种简单的springboot启动后启动一条死循环线程方式

    前言 之前有测试 # 启动类加 @EnableAsync # 方法上加注解 @Async @PostConstruct 但是依旧会卡主主线程,所有另辟蹊径 第一种 在启动类上加注解 @EnableAs ...

  7. Docker Compose部署随机图API

    Docker Compose部署随机图API 平时我们部署博客的时候,为了考虑美观会考虑使用随机图来作为文章的封面,现在有很多大佬愿意提供随机图API,通过API我们可以很方便地部署随机图,不必自己寻 ...

  8. 直播预约丨《袋鼠云大数据实操指南》No.4:数据服务API实战解读,助力企业数字化跃迁

    近年来,新质生产力.数据要素及数据资产入表等新兴概念犹如一股强劲的浪潮,持续冲击并革新着企业数字化转型的观念视野,昭示着一个以数据为核心驱动力的新时代正稳步启幕. 面对这些引领经济转型的新兴概念,为了 ...

  9. 数据安全新战场,EasyMR为企业筑起“安全防线”

    2020年1月,时间跨度长达14年的,微软2.5亿条客户服务和支持记录在网上泄露: 同年4月,微盟发生史上最贵"删库跑路"事件,造成微盟市值一夜之间缩水约24亿港币: 今年7月,网 ...

  10. .Net Web API 002 Program和WeatherForecastController

    创建工程后,工程主要包含了Program.cs和WeatherForecastController.cs两个代码文件,还有一个WeatherForecast.cs文件,该文件定义的天气情况数据结构替, ...