更復雜的代碼,為何跑得快了10倍?一次Draw Call優化引發的思考
大家好,最近我挖了一個新的開源項目坑: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 調用: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 的各種有趣玩法!

浙公網安備 33010602011771號