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

大家好,最近我挖了一个新的开源项目坑: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 调用: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 的各种有趣玩法!