回顧并為今天定下基調
今天的主要任務是讓我們的性能分析工具正常工作,因為昨天已經完成了結構性工作。現在,剩下的工作大部分應該是調試和美化。性能分析工具現在應該已經基本可用了。昨天我們在這個方面取得了很大的進展。
接下來,我們將開始調試,并展示目前的成果。首先加載了代碼庫,并準備好進行編譯。
運行游戲并查看當前的性能分析狀態
當前,我們遇到了性能分析工具的一個問題,具體表現為數據記錄不正確。在查看分析結果時,圖表顯示的數據完全沒有意義,存在明顯的錯誤。我們推測,問題可能出在數據收集的過程中,尤其是在使用絕對時間鐘時可能出現了問題。
這種錯誤似乎具有周期性,可能是在記錄每一幀的數據時出現了問題,導致某些操作使用的絕對時鐘值有誤。具體來說,在處理64位時鐘時,某些地方的轉換可能發生了錯誤,導致了不正確的結果。最終,這種錯誤影響了數據的準確性,甚至影響了整個圖表的顯示。
為了解決這個問題,首先需要回顧一下之前寫的代碼。因為我們并沒有仔細檢查代碼,只是簡單地寫完后運行了一次,所以現在需要分析一下我們是如何記錄這些信息的。重點是要檢查與記錄過程相關的代碼,找到問題的根源。首先,我們將從分析涉及數據記錄的代碼開始,了解其具體實現,進一步分析可能的錯誤原因。
game_debug.cpp:回顧性能分析代碼
在性能分析工具中,記錄時間塊的時機是當代碼遇到打開語句時。例如,當我們進入一個時間塊時,函數調用會打開一個時間塊,記錄開始事件。當代碼執行到這一行時,會記錄一個開始塊(begin block)。當時間塊離開作用域時,會通過一個構造函數和析構函數自動結束塊,記錄結束事件。
當看到開始塊時,我們會檢查當前線程是否已經有一個正在打開的時間塊。這個檢查是按線程進行的,因為可能會有多個線程同時執行,而每個線程都有自己的時間塊。為了避免使用全局變量或共享狀態來跟蹤當前線程的時間塊,每個線程都有自己的記錄。通過在編譯期間檢查當前線程的狀態,可以確保每個線程的時間塊是獨立的。
如果發現當前線程已有打開的時間塊,我們會選擇這個時間塊作為父事件,繼承它的時鐘基準。也就是說,所有后續的時間塊都會以該時鐘基準為參考。時鐘基準是打開時間塊時的時鐘值,它必須不斷增加,因為每個新打開的時間塊必須有一個大于上一個時間塊的時鐘值。如果新時間塊的時鐘值小于上一個時間塊的時鐘值,這意味著出現了嚴重的錯誤。為了避免這種情況,應該對時鐘值進行斷言檢查,確保時鐘值始終是遞增的。
在理論上,時鐘的遞增速度非常快,大約每秒增加四十億次,因此即使機器運行了很長時間,時鐘也不會回繞。實際上,這意味著我們不太可能遇到時鐘回繞的情況,因為它需要非常長的時間才會發生。
如果當前線程沒有打開的時間塊,這意味著我們沒有根時間塊,即整個幀的根時間塊。此時,我們需要為這一幀創建一個根時間塊來存儲數據。這時候,我們會創建一個虛擬事件,初始化一個根時間塊,它基本上沒有任何信息,只有一個持續時間值,表示這一幀的時長。
在這個過程中,發現了一個bug,可能是根時間塊的記錄存在問題,導致性能數據無法正確收集。
game_debug.cpp:注意我們在寫入之前使用了 EndClock 值
發現了一個bug,這個問題很簡單:在計算持續時間時,使用了一個尚未寫入的結束時鐘值。具體來說,當當前使用的合并幀(collation frame)時,結束時鐘值并沒有被寫入。由于在此時,根本沒有寫入結束時鐘的操作,因此無法正確計算持續時間。這是一個必須修復的bug,因為沒有結束時鐘值,計算出來的持續時間肯定是錯誤的。
game_debug.cpp:在 CollateDebugRecords 中計算時長并正確設置 ClockBasis
問題的解決方法是將持續時間的設置推遲到另一個時機。假設當前的持續時間為零,可能還存在其他問題,但我們主要的修復是,將持續時間的計算放在“合并幀”最終確定并作為一幀添加時進行。此時,幀邊界標記已經被記錄,結束時鐘也已經被記錄,這就是進行計算的合適時機。
具體來說,當合并幀有根節點時,應該在此時計算持續時間,并更新相應的數值。此外,還有一個問題是時鐘基準值(clock basis)沒有被設置,這是另一個bug。時鐘基準值應該設置為打開事件的時鐘,但如果沒有打開事件,則應該設置為幀的初始時鐘值,因為我們知道這個值。
運行游戲并查看更合適的性能分析結果
問題的根本原因就是之前沒有正確地設置持續時間和時鐘基準值,導致了明顯的bug。這兩個問題的修復非常直接,經過修復后,得到的性能數據看起來更加合理了。
在修復后,雖然我們能看到更準確的分析數據,但仍然存在一些額外的功能需求。例如,雖然可以暫停分析數據的收集,但是目前并沒有實現暫停功能。我們需要在某個時刻實現暫停功能,這樣就能停止收集性能數據,避免不必要的數據干擾。
同時,當前我們還想實現一個功能,即能夠在界面上懸停在某個分析區域上時,看到這個區域的具體內容。之前我們已經有類似的功能,但由于缺少相關的輸出功能,暫時沒能完全實現。因此,接下來需要重新啟用之前的代碼,這樣就能夠顯示這些信息了。
game_debug.cpp:重新啟用并重寫懸停時的文本
目前的問題是,雖然我們有一些關于分析區域的信息,比如周期計數和網格信息等,但由于缺少必要的輸出功能,之前的輸出方式無法再使用。為了初步解決這個問題,計劃直接將已知的信息打印出來。可以簡單地打印出該區域的網格信息、持續時間以及相關的周期數據。雖然這只是一個臨時解決方案,未來可能會進行一些更復雜的優化和美化,但作為起步,當前的做法已經足夠實用。
運行游戲并查看懸停時的文本
當前的問題是無法一致地為每個分析區域分配不同的顏色,這使得不同的區域可能會顯示相同的顏色,從而難以區分。為了改善這一點,需要找到一種方法,確保每個區域能夠有獨特的顏色。現在所做的做法存在問題,因為使用了指針并且通過計數值來生成顏色,但由于指針的底部位不會存儲信息,這種方法是不合適的。指針底部位沒有存儲數據的特點使得這種做法變得不可靠。因此,需要重新考慮如何生成唯一且合理的顏色分配方式,這對于進行低級編程時尤其重要。
Blackboard:指針對齊和從數組中隨機挑選
為了隨機選擇每個區域的顏色,采取了一種方法,即通過指針的數值來生成顏色。指針指向的內存地址唯一,因此可以通過指針的數值來確保每個區域的顏色不相同。這種方法的基本原理是將指針的數值與顏色數相除,得到一個索引,然后通過這個索引從顏色數組中選擇顏色,從而實現隨機顏色分配。然而,問題在于指針通常是對齊到四字節的邊界,也就是說,指針的數值在低位部分的變化非常有限。例如,指針值通常會是0、4、8、12等,極少出現1、2、3等數值。這就導致了顏色分配時,實際可用的顏色非常少,最多只能用到三種顏色。
此外,顏色數組的大小恰好是四的倍數,這意味著每個顏色只能分配給一小部分區域,而不會有更多的顏色可供選擇。為了更均勻和多樣化的顏色分配,理想情況下應該使用一個質數大小的顏色數組,這樣可以避免出現顏色重復和分配不均的情況。
game_debug.cpp:讓 Colors 數組不是四的倍數
為了避免剛才提到的問題,我決定采取一種簡單且有趣的方式來解決它。我決定將顏色數組的大小設置為非 4 的倍數,以便讓顏色分配有更多的變頻效果。實際上,如果我們能選擇一個質數作為數組的大小,那就更好了。
目前數組的大小是 12,我考慮將其調整為 11,這樣它就是質數了。因為質數的特性,可以避免指針值由于內存對齊問題而集中分配相同的顏色,這樣可以讓每個顏色在分配時更加均勻。為了避免使用到最后一個顏色,我決定去掉這個最后的顏色值,保持 11 個顏色的數組。
這樣做可以有效地讓顏色分配更加均勻,避免了之前由于對齊問題導致的顏色重復使用的情況。
假設我們有一個顏色數組,最初定義為:
int colorArray[12] = {color0, color1, color2, color3, color4, color5, color6, color7, color8, color9, color10, color11};
在此情況下,數組大小為 12,它是 4 的倍數。假設我們的指針地址(例如,內存中的地址)在內存對齊后是 4 字節對齊的,那么我們只會看到其中的某些顏色。例如:
- 地址 0x1000 對應 color0
- 地址 0x1004 對應 color4
- 地址 0x1008 對應 color8
由于內存的對齊特性,指針地址在 4 字節的邊界上,只會影響到顏色數組中的部分顏色,導致有些顏色會被重復使用。
為了避免這種問題,可以將顏色數組的大小改為 11,選擇一個質數大小,這樣可以避免指針地址的對齊影響顏色的選擇。我們改成:
int colorArray[11] = {color0, color1, color2, color3, color4, color5, color6, color7, color8, color9, color10};
這樣,當指針通過每個元素時,顏色分配會更加均勻,不會因為對齊問題而集中選擇某些特定顏色。通過這種方式,可以獲得更好的顏色分配,避免顏色重復使用的問題。
運行游戲并查看性能分析器中的不同區域
現在,通過調整顏色的分配方式,能夠清晰地看到不同的區域了。我們成功地使用了比之前更多的顏色值,這使得配置更加穩定,幫助我們更清楚地觀察到性能分析結果。從現在的視角來看,已經得到了更為直觀的信息。
通過這些信息,可以大致看到每一幀的時間是如何劃分的。其中,最大的一段亮眼的熒光條顯示的是幀顯示時間,也就是等待垂直刷新時的時間。此時,代碼中正進行著大量工作,這些工作就在圖表的某個區域中顯示出來。
接下來,輸入處理的部分代碼被分配到了一部分區域。雖然我們對這段時間的長短有些疑問,但這可能是正確的,值得繼續觀察和驗證。
進一步查看時,可以看到實際的游戲代碼的執行時間。然后,調試聚合的時間顯示得非常明顯,因為調試過程中需要處理大量的信息,這部分顯得尤其昂貴。
此時,我們可以通過繼續分析和觀察,進一步驗證這些數據,發現哪些部分的性能開銷過大,并找出可能存在的優化點。
game_render_group.cpp:向 object_transform 中添加 SortBias,使 SortKey 自動考慮它
在繼續之前,需要先處理一個問題,那就是要確保我們的性能分析顯示層位于最前面。之前處理精靈時,已經使用了層級排序的方式來確保調試代碼能夠顯示在最前面,現在同樣需要在性能分析顯示中應用這一層級偏移(z-bias)。如果不處理好這一點,性能分析的數據就無法正確顯示。
回想一下,渲染是從前到后排序的。如果我們不確保調試信息的渲染順序將其放到最前面,就會遇到這個問題。為了解決這個問題,需要在渲染過程中正確設置調試信息的顯示順序,確保其位于最前。
接下來,需要找到渲染代碼中的偏移設置。具體來說,是要將層級偏移(sort bias)與物體變換(object transform)相關聯。物體變換已經在渲染過程中使用了,將偏移的概念放入物體變換中會更合適,這樣就不需要在每次調用渲染時都傳遞偏移參數,只需要使用物體的變換信息即可。
這將使代碼更加簡潔,確保調試信息始終位于最前面,方便查看性能分析結果。
我們在 object_transform
中添加了一個新的字段:sort_bias
,用于指定圖形對象在渲染排序中的優先級。這樣做的主要目的,是為了解決當前存在的調試信息無法正確渲染在最前端的問題。
目前的渲染系統是基于從前到后的順序進行繪制的,即所謂的 z 軸排序。如果不顯式地設置調試信息的排序偏移,它們很容易被其他圖形遮擋,導致分析圖形不可見。因此,為了讓調試圖層始終處于前方,我們將排序偏移值整合到 transform(變換)信息中。
為了實現這一目標,我們在計算 render 實體的基礎信息時做了以下改動:
- 在
object_transform
中添加了sort_bias
字段,這個偏移值用于控制當前對象在渲染列表中的排序位置。 - 在調用
get_render_entity_basis_p()
函數時,通過傳入object_transform
,我們可以從中獲取到當前對象的sort_bias
。 - 在這個函數中我們原本的做法是基于 z 值和 y 值來計算排序鍵(sort key),通過減去 y 值來讓物體有個基于垂直方向的深度變化,從而模擬一個 2.5D 的效果。
- 我們現在在計算排序鍵之前,先用
sort_bias
作為初始值,然后再在這個基礎上附加 z 和 y 的信息,這樣我們可以靈活地控制對象的渲染順序。 - 這樣處理之后,無需在各個調用點都顯式傳遞
sort_bias
,只要設置好 transform 結構,就能自動完成偏移應用,渲染邏輯更加清晰統一。
總的來說,這種處理方式讓渲染排序更加可控,也避免了原本那種“穿透式傳參”的繁瑣過程,從結構上簡化了渲染系統,并確保調試圖層在性能分析過程中始終可見。
game_debug.cpp:在 DEBUGTextOp 中引入 Transforms
我們將之前用于處理 sort_bias
的零散邏輯,整合進了 transform
中,并開始定義幾個特定用途的標準 transform,用來區分調試信息的渲染層級。原本在渲染過程中使用 sort_bias
的方式比較零散、不規范,因此我們希望通過結構化處理,把這種偏移信息規范地附加到 transform 對象上,以便統一使用。
我們當前做了以下調整和規劃:
-
將 Sort Bias 納入 Transform:
之前sort_bias
是以某種“臨時參數”的形式出現,現在我們將其明確為transform
結構中的一個字段,這樣每個渲染實體都能在自身定義中明確其渲染層級偏移。 -
定義兩個特殊 Transform:
text_transform
:用于調試文字的渲染。shadow_transform
:用于調試陰影或輔助圖形的渲染。
這兩個 transform 被賦予了非常大的sort_bias
數值,確保它們總是排在渲染順序的最前面,從而始終可見,不會被其它圖形遮擋。
-
未來集成到 Debug 系統:
目前這兩個 transform 是臨時寫死在代碼中的,但計劃將它們作為全局常量或配置項,正式集成進調試系統(debug state)中,便于全局調用和維護。 -
分層渲染機制構建:
通過定義這些具有明確用途的 transform,我們可以將不同類型的調試信息(比如文本、陰影、輪廓、背景塊等)安排在不同的“視覺層”中。只需在渲染調用時選擇合適的 transform,就能自動實現合理的前后遮擋關系,無需手動控制順序。 -
結果與預期:
這樣一來,所有調試信息都能始終被正確渲染在最前方,便于觀察。渲染系統結構也更加清晰、可維護。
總結來說,這種做法通過結構化設計和合理分層,把原本混亂的調試信息渲染邏輯規范化,為后續調試系統擴展和可視化調優打下了堅實基礎。
運行游戲并注意到我們的問題還沒有解決
我們目前的問題還未解決,是因為還沒有將相關設置應用到另一個關鍵的渲染例程中。雖然我們在一處進行了調整,但在切換到另一部分代碼時,效果依然不正確。因此接下來的目標是將這些 transform(變換信息)徹底變成調試系統的“一級公民”(first-class citizens),以便在所有調試例程中統一使用。
為此我們進行了如下處理:
-
將標準 transform 整合進調試系統:
把之前用于文字顯示、陰影、調試標識等內容的 transform,不再每次手動設置,而是將它們直接內嵌到調試系統狀態(debug state)中。這樣這些 transform 成為常駐資源,可以在任何調試繪制過程中直接使用,無需重復定義或傳參。 -
提升系統一致性和易用性:
把這些 transform 定義為調試系統的一部分,意味著任何時候進行調試圖形繪制,都能一致性地使用這些預設的渲染層級。這解決了過去手動設置帶來的不便,也避免了重復代碼。 -
目標是默認可用:
一旦被集成,它們將在整個渲染生命周期中保持可用狀態,任何調試渲染函數都能立即使用,無需額外配置。 -
后續擴展空間:
一旦這些標準 transform 成為調試系統的內建元素,就可以更方便地添加更多類別的調試層(如背景層、網格層、標簽層等),為調試信息提供更清晰的視覺分離。
總之,此舉使調試渲染變得更系統化、更自動化,也為后續功能擴展提供了結構上的支持。
game_debug.h:向 debug_state 中添加 Transforms
我們將一些常用的 transform 直接放入了調試狀態(debug state)中,使它們始終可用,無需在使用前手動設置。比如,文字用的 transform、陰影用的 transform 現在都作為系統內建的一部分了。
此外,我們意識到可能還需要更多層次的 transform,比如專用于背景的 backing transform。這樣我們可以按需選擇不同的層級,用于渲染不同的調試元素,從而構建出一個更清晰的分層調試視圖。
這些 transform 的整合目的是建立一個可復用、清晰分層的系統,我們可以為每種調試信息指定一個獨立的顯示層,比如:
- 文字層(Text)
- 陰影層(Shadow)
- 背景層(Backing)
未來可能還會添加更多,比如網格層、UI 層等等。
在進行這些調整的同時,也注意到自己的編輯環境需要優化。目前使用的是 FourCoder 編輯器,啟用了模態編輯模式(類似 Vim 的操作方式),但因為一開始設置粗糙,加上一些 bug,導致鍵位綁定和操作效率還沒完全調整好。現在因為已經切換到用 FourCoder 做主要開發環境,所以也希望在最近抽出時間對它進行全面優化,調整快捷鍵,使自己的開發效率能夠回到從前非模態操作時的水平。
這是一個很自然的過渡過程,從熟悉的傳統編輯方式轉向模態編輯,需要一段適應期,同時也要求我們投入時間去配置和調教工具,才能真正釋放出模態編輯帶來的優勢。希望能盡快完成這些設置,讓開發流程更流暢、高效。
game_debug.cpp:在 DEBUGStart 中初始化 Transforms
我們開始對調試系統中用于圖形層級顯示的 transform 進行初始化設置。目標是為調試渲染(尤其是性能分析的可視化)指定一套專門的 transform,使調試信息總是處于最上層,不會被其他游戲內容遮擋。
為此,我們將這些 transform 加入初始化流程中,并分配給它們一些特定的、足夠大的值,確保它們的排序優先級始終高于游戲中的其他實體。具體來說,我們設置了一個 tracking transform,用于顯示性能分析的圖形,它的排序值必須比游戲中的任何其他 transform 都要大,這樣才能保證它始終顯示在最前面。
雖然 transform 的排序值分配沒有復雜算法,只需要滿足比游戲中可能使用的值更大即可,但這是一個關鍵的系統設計點,能顯著提升調試信息的可視性和穩定性。
最后,我們將這些新的 transform 應用于性能分析的繪制代碼中,特別是 draw_profile
相關的地方。通過替換原有的 transform,確保調試圖形在渲染中使用我們剛剛設定的更高優先級,從而實現信息圖層的正確顯示順序。這樣在查看性能分析時,就不會被遮擋,也更容易閱讀和理解。
game_debug.cpp:將 BackingTransform 傳遞給 DrawProfileIn 中的 PushRect 調用
我們接下來要做的工作是讓我們繪制的所有矩形都使用新的 transform,以便統一地控制其在渲染中的顯示順序。好在我們之前設計得比較合理,push_rect
接口本身就已經支持傳入 transform,這使得改動比較順利。
我們只需將調試狀態中的 backing_transform
傳入這些繪制調用中即可。這樣一來,所有繪制的調試矩形(例如性能分析可視化框)都會自動使用這個 transform,從而在渲染中保持統一的層級排序。這種結構清晰而易于維護。
我們也檢查了一些現有代碼,發現還有其他繪制調用,比如繪制角標等界面元素的地方,也需要使用正確的 transform。這些元素也必須被正確地顯示在前方,所以我們將這些繪制調用統一修改為使用 debug_state.backing_transform
。
例如,原先繪制角落或者高亮的邏輯,現在也需要加上對應的 transform 參數。為簡化修改過程,我們采用了較直接的方式,把傳入的 transform 設為 debug_state.backing_transform
,這樣就能確保所有調試相關圖形都能正確地顯示在其他游戲內容之上。
整體上,我們通過系統地接入統一的 transform,確保了所有調試繪圖的圖層一致性、渲染可視性,并為后續的調試可視化打下了清晰可控的基礎。
運行游戲并查看我們的性能分析器
現在我們查看最新的性能分析視圖,觀察剛才的調整是否生效。啟動調試后,按下空格鍵,確實可以看到性能視圖正確顯示了,這非常理想。
在當前視圖中,不同顏色的區塊分別代表游戲主循環中各個階段的耗時情況:
- 某一段明顯的時間塊表示游戲邏輯更新階段所耗費的時間。
- 另一段表示輸入處理過程的耗時。
- 還有一段時間花費在一些可能與執行環境有關的過程(如可執行文件的啟動、指令調度等),目前無法具體查看。
- 接下來是調試信息的整理時間,這一段在完整游戲運行時會顯著增加,因為調試系統需要處理的實體和數據更多。
- 最后一段顏色非常鮮艷、明顯,是幀顯示階段的等待時間,也就是等待垂直同步(VSync)或幀緩沖交換的過程。
需要注意的是,這一階段的時間并不純粹代表顯卡正在忙于繪制內容,其中一部分也可能只是等待垂直刷新間隔(v-blank)的空轉時間。由于缺乏硬件級的深度信息,我們無法確定此段時間中,顯卡到底是在執行任務還是處于空等狀態。
此外,圖中還顯示出這一幀時間線中的不同階段可能存在重疊,尤其是幀顯示階段,它與 CPU 邏輯處理之間不是嚴格順序發生,而是存在一定程度的并行和交錯。
通過這樣的分析,我們已經能夠較為直觀地理解一幀時間中各個系統模塊的工作分布和瓶頸位置,這為后續的性能優化提供了有力的數據支持。
Blackboard:性能分析器顯示的內容可能發生在 SwapBuffers 調用之后
我們進一步深入分析幀時間的表現和機制,嘗試理解當前幀等待和渲染背后的本質運作。
每一幀的時間線可以被想象成多個不同階段的堆疊過程。例如:
- 一段是游戲邏輯的處理;
- 一段是輸入的處理;
- 一段是調試信息的整理;
- 還有一段是幀顯示階段(也就是等待垂直同步的部分)。
這些部分按順序組成一幀的完整生命周期。幀顯示階段通常是圖形系統在等待垂直同步(v-blank),以確保畫面更新不會發生撕裂。但重要的是,這一過程并不像看起來那么線性或單一。
現代硬件上的幀渲染流程是異步的,CPU 和 GPU 并不是一對一、逐幀地完全同步協作。可以想象如下的幀流水線:
- 當前顯示的是第 4 幀;
- 我們在處理的是第 5 幀;
- 圖形卡可能正準備繪制第 6 幀;
- 而我們調用
SwapBuffers
或類似函數時,其實并不是立刻渲染或等待當前幀,而是在觸發一次交換的過程,并且啟動了后續幀的準備。
換句話說:
- 我們等待的其實可能是前一幀的垂直同步信號;
- 當前幀在
SwapBuffers
時可能已經完成繪制,等待顯示; - 同時新一幀的繪制準備也可能已經開始。
這種行為是典型的幀管線重疊優化,能夠提高 GPU 和 CPU 的資源利用率。雖然會引入一幀的輸入延遲(Latency),但這比降低幀率帶來的響應滯后更具優勢。
例如:
- 在 60FPS 下,每幀有 1/60 秒的時間可以進行工作;
- 如果允許一幀延遲,那么 CPU 和 GPU 都可以各自獨立地利用這段時間;
- 相比于鎖步運行或降至 30FPS,這種方式總體響應更好、畫面更流暢。
需要注意的是,這種行為在現代操作系統和驅動中非常常見,尤其是當啟用了垂直同步時,硬件和驅動往往會插入這類緩沖策略來保障幀率穩定性和系統流暢性。
總結來說:
- 幀顯示階段不是簡單的“現在開始繪制然后等待顯示”,而是復雜的異步重疊;
- CPU 和 GPU 可能各自領先或滯后一幀;
- 這樣安排能更好地利用資源,但會增加一幀的延遲;
- 了解這種機制,有助于分析一些幀率抖動、輸入延遲或性能瓶頸問題;
- 雖然游戲中通常無需過于深入理解細節,但對系統表現有個基本認知,會對調試優化非常有幫助。
指出性能分析的有用性
我們在這里強調的是性能分析(profiling)系統的價值,也解釋了為什么需要構建一個調試系統,并把它當成開發流程中不可或缺的一部分。
到目前為止,我們所做的只是展示了頂層的調試信息。我們甚至還沒有實現點擊進入某個區域、查看其內部子項的功能。只是簡單地渲染出了最外層的分析視圖。即使是這樣基礎的功能,也已經讓我們發現了有意義的性能數據。
例如:
- 當前能看到一個很顯眼的區域是“輸入處理”;
- 這個階段消耗了大約兩百萬個周期(CPU cycles);
- 這個數字非常大,引起了我們的關注。
回顧一下,我們之前討論過 CPU 周期的含義:
- 一次緩存未命中(cache miss)可能花費大約 100~200 個周期;
- 這是非常昂貴的操作,我們一直強調要避免;
- 所以兩百萬個周期的耗時,就顯得非常不合理。
這讓我們馬上就產生疑問:輸入處理怎么可能消耗如此大的時間?
進一步分析:
- 當前程序正在處理大約兩千個調試事件;
- 更新了成百上千個實體;
- 所有這些邏輯都是調試系統代碼;
- 而且尚未做過任何優化,完全是初始實現狀態;
- 然而,輸入處理的耗時依然占據了整體時間中一個明顯比例,這在視覺化圖表中非常清楚地反映了出來。
這意味著我們需要深入排查這個區域,因為這很可能是性能瓶頸之一。這個結果讓我們立刻警覺:“這不合理”。
在有經驗的程序員直覺驅動下,我們大概猜到了可能的原因。因為做程序久了,就會逐漸建立起對某些現象背后原因的直覺。有些人可能在過去我們某次講解中聽到過這個線索,但因為當時只是順帶提到,可能已經忘了。
如果是使用 Windows 平臺開發的老手,可能一看這個現象就已經知道我們在想什么了——我們可能已經猜測到了真正的問題所在,盡管還未驗證。
因此這部分內容的核心意義是:
- 性能可視化是發現瓶頸的關鍵工具;
- 即使調試系統還沒完全做完,基礎框架就已經能暴露出一些重要線索;
- 通過周期數與經驗對比,可以判斷某些行為是否異常;
- 哪怕只是頂層信息,也能引導我們深入挖掘問題源頭;
- 構建一個強大調試系統的重要性由此體現出來。
win32_game.cpp:調查輸入處理發生了什么
我們現在開始著手排查性能問題,并不打算一開始就假設自己知道該注釋掉哪部分代碼,而是希望借助已有的工具,逐步定位問題。
首先,我們關注的是“輸入處理”部分的性能問題。即使不深入思考具體原因,也可以采用逐步縮小范圍的策略,使用性能分析(profiling)工具來定位問題。
具體操作如下:
- 我們清楚所有造成性能消耗的代碼,一定在某個
begin
和end
的時間測量塊之間; - 所以只要在該區域內部繼續劃分子模塊的時間塊,就能快速排除哪些代碼不是問題源;
- 即使完全不知道是哪部分代碼的問題,也可以用這種“分而治之”的方法來調查;
- 接下來我們在不同的邏輯段落中嵌套了多個性能分析塊(profiling block),給它們起名,以便后續觀察數據時可以知道哪部分耗時最多。
劃分的邏輯塊包括:
-
控制器清除邏輯(controller clearing)
- 這段代碼是我們自己的,獨立執行;
-
Win32 消息處理邏輯(win32 message processing)
-
我們將其進一步拆分為:
- 鍵盤消息處理;
- 鼠標同步處理;
- 其他鍵盤相關消息;
- Xbox 控制器輸入處理;
- 新增的鍵盤控制器。
-
其中一些模塊被顯式包裹在測量塊中,也有一些沒有嵌套,只是放置在結構上稍微抬高一點的位置。但整體目標是相同的:通過這些測量塊,我們就能在之后運行時看到每一小段邏輯到底花了多少時間。
特別說明:
- 某些我們主觀上不認為會出問題的部分(例如最后那個新鍵盤控制器邏輯),我們暫時不加測量塊;
- 但是如果后續觀察到它也包含在外層大耗時塊中,那說明我們判斷可能有誤,還得繼續深入;
- 這種方式允許我們以很小的代價快速排查、驗證假設,逐步定位真實的性能瓶頸。
總結:
- 我們在沒有完全確認問題源的前提下,采用了非常實用的“劃分測量區域 + 觀察”的方法;
- 通過在輸入處理函數內嵌套多段 profiling 代碼塊,我們可以精確地知道哪個步驟耗時最多;
- 這是一種高效、可重復的定位性能問題的方法;
- 同時也體現了調試工具(profiling blocks)的重要性,它能夠讓我們在復雜系統中迅速縮小問題范圍。
運行游戲并注意到我們需要能夠縮小到某個性能區域
接下來我們希望實現的新功能,是在已有調試系統的基礎上,進一步支持深入查看某個性能區塊的內部結構。
當前的情況是:
雖然我們已經在代碼中添加了多個計時塊(profiling blocks),這些計時塊在運行時確實會被統計,但界面上只顯示了最頂層的區塊。比如我們可以看到“輸入處理”這整個大塊,但無法看到它內部更細致的子結構。
為了解決這個問題,我們準備做以下改進:
-
實現一種機制,可以選中一個較大的性能區塊,然后**“展開”查看其內部被嵌套的子區塊**;
-
例如,如果我們發現“輸入處理”這塊的性能開銷很高,我們就可以點擊它,然后查看它里面具體的組成部分,比如“清除控制器”“鼠標同步”“鍵盤消息處理”等分別花了多少時間;
-
這樣做的好處在于:
- 一旦我們發現某個區域很耗時,我們就能馬上細化觀測;
- 不需要通過“猜”或者逐步注釋代碼的方式排查問題;
- 可以很快定位到底是哪一段代碼產生了性能瓶頸;
- 整個調試流程變得更加直觀、數據化、結構清晰。
總之,這是在調試系統中非常重要的一步改進:
從只能看到總覽,進化到可以層層深入查看細節。這不僅提升了我們定位問題的效率,也讓整個性能分析工具更加實用和智能化。我們希望通過這次優化,讓調試體驗更加流暢,為后續的系統優化打下堅實基礎。
win32_game.cpp:注釋掉“輸入處理”部分
現在我們也可以采用一種簡單方式來查看內部結構,那就是直接去掉外層的計時塊,這樣所有子計時塊就會直接出現在頂層視圖中。
比如說,如果我們不包裹“輸入處理”這個大塊,而是單獨顯示內部的“控制器清除”“鼠標同步”“鍵盤消息處理”等,那么這些內部步驟就都會顯示在主視圖中,能直接看到每個步驟的耗時。
這種做法確實可以臨時讓我們看到具體細節,但也暴露出一個問題:
- 如果我們所有的子塊都在頂層展示,整體視圖會變得非常混亂;
- 一旦嵌套變多,根本沒辦法快速分組和分析;
- 缺少了結構性,調試視圖就會失控。
這也正是我們不想把所有內容都放在頂層的原因。
我們真正需要的是一種支持嵌套結構展開與收起的調試機制。這樣既能保持整體邏輯清晰,又能在需要時快速下鉆查看內部細節。每個頂層塊相當于一個容器,當我們發現其中有問題時,只需點擊它就能展開,看到其中每一步的具體耗時。
因此,當前的臨時方案雖然能起到展示作用,但從長遠看,還是要回歸到結構化的設計思路,實現按層級展開性能數據的功能。這將讓調試流程更高效、邏輯更清晰,也更適合長期維護與優化。
運行游戲并發現 XBox 控制器輪詢需要 200 萬周期
現在來看一下到底是哪部分代碼真正消耗了時間。
果不其然,猜測是正確的:輪詢 Xbox 控制器的操作竟然消耗了約 兩百萬個 CPU 周期。這是非常高的數值。
這個現象引發了疑問:為什么只是檢測 Xbox 控制器是否存在,就要花掉兩百萬個周期?
從直覺來看,這種輪詢操作本應非常輕量,只需要快速檢查一下設備狀態就好。可是現實卻是,這段驅動層的代碼執行極其緩慢。更令人疑惑的是,這種查詢的核心目標——“控制器是否連接”——本就是個經常不確定的事情:有時控制器在、有時不在,而這本應是驅動設計時重點考慮的部分。
然而,驅動的實現方式卻完全不像是對效率有所關注,反而給人一種完全不清楚硬件狀態管理流程的感覺。如果有誰真正了解系統底層設備狀態輪詢機制的話,大概根本不會以這種方式寫代碼。
總結來看:
- Xbox 控制器的輪詢操作存在明顯的性能瓶頸;
- 實現方式可能非常低效;
- 造成了大量不必要的周期浪費;
- 這可能并非程序本身的性能問題,而是調用了一個系統層面本身就效率極差的接口。
這種問題也說明了為什么在調試過程中,構建精確的性能分析工具非常重要。只有準確定位耗時,我們才能判斷性能瓶頸是來自自身邏輯,還是底層外部庫的實現問題。
我的20萬
描述并考慮如何解決 XInputGetState 在輪詢 XBox 控制器時的已知 bug
從分析結果可以非常直觀地看出,整個輸入處理階段的大部分時間幾乎全部都被 Xbox 控制器的輪詢操作耗盡了。簡直像“桶里撈魚”一樣容易定位問題。每幀我們都在消耗 兩百萬個 CPU 周期,但實際并沒有獲取到任何 Xbox 控制器的輸入。
更嚴重的是,根本就沒有插著任何 Xbox 控制器。
這個問題實際上是一個早就廣為人知的系統層級 Bug。具體表現是這樣的:
- 當調用
XInputGetState
并且對應編號的控制器確實存在時,這個調用是非常快速的,幾乎不耗費任何時間; - 但如果調用的是一個并不存在控制器的編號,這個函數就會嚴重阻塞,消耗大約 50 萬個 CPU 周期(兩百萬除以輪詢四個控制器);
- 即使只是為了確認“控制器不存在”,這個函數也會讓 CPU 空轉極長時間。
這顯然是非常嚴重的效率問題。調用本應是輕量的存在判斷操作,結果卻帶來了巨大的性能開銷。
面對這個 Bug,實際的操作系統或底層庫并沒有修復。雖然這個問題早在 2008 年就被報告過,但至今依然存在。因此,只能在應用層做規避處理。
我們可以采取一些權宜之計來降低性能影響:
臨時優化方案:
-
僅輪詢已知存在的控制器:對于真正插著的控制器,每幀都正常輪詢;
-
未插入的控制器采用輪詢間隔策略:比如一共有 3 個控制器未連接,那么可以:
- 第一幀輪詢編號為 1 的;
- 第二幀輪詢編號為 2 的;
- 第三幀輪詢編號為 3 的;
- 然后循環回第一幀;
- 這樣每幀最多只損失約 50 萬個周期,而不是 200 萬。
存在的問題:
- 無法完全避免性能浪費:即使優化,也還是每幀損失幾十萬周期;
- 帶來新的不穩定性:某些幀性能會突然下降,形成“性能尖刺”;
- 延遲感知控制器插入:在控制器插入時,檢測可能會有幀級延遲。
更理想的解決方式:
為了從根本上解決問題,需要使用系統級的設備狀態通知機制,比如:
- 注冊設備變更通知回調;
- 監聽 USB/HID 輸入設備變動;
- 當真正檢測到控制器插入時,再開始對其調用
XInputGetState
。
這樣就避免了持續調用阻塞的函數,也杜絕了在控制器未插入時的高性能浪費。
總結:
- 問題出在
XInputGetState
調用; - 控制器未連接時調用它會嚴重浪費 CPU;
- 可以用間隔輪詢緩解,但仍不是徹底方案;
- 最終需要通過設備變更事件檢測來規避這類調用;
- 這個問題不是開發邏輯錯誤,而是系統 API 設計/實現層面的嚴重性能缺陷。
這也再次強調了性能分析工具的重要性:沒有精細的時間分析,根本不可能定位這種“看似正常的 API 調用”其實暗藏巨大性能黑洞的問題。
win32_game.cpp:引入 XBoxControllerPresent,假設它們在啟動時都已連接,并在意識到它們沒有時停止輪詢
當前我們已經準備好進入優化下一步了。接下來要做的是構建一個基礎框架,為將來更徹底解決 Xbox 控制器輪詢問題打下基礎。現在我們先做一個臨時性的繞過方案,目的是避免每幀調用那些會造成 CPU 阻塞的控制器輪詢邏輯。
當前優化步驟:
-
引入一個持久狀態數組,放在主循環開始之前的位置:
- 數組名如
xbox_controller_present[]
; - 初始化時默認假設所有控制器都未連接,除了第一個控制器假設是連接的;
- 未來計劃讓這個數組實時更新控制器的連接狀態。
- 數組名如
-
在主循環里判斷控制器是否“存在”再決定是否調用
XInputGetState
:- 只有在
xbox_controller_present[i] == true
的時候,才會對對應編號的控制器執行輸入輪詢; - 如果某個控制器在調用時發現并未連接,就將對應標志設置為
false
,從此不再輪詢它; - 這樣可以有效避免大量無意義的調用導致 CPU 阻塞。
- 只有在
-
一次性初始化所有控制器狀態為“已連接”:
- 在進入主循環前,通過循環將
xbox_controller_present
全部設置為true
; - 實際這是一種簡化方案,在邏輯上假設控制器在程序啟動時都插好了;
- 但這也意味著:如果控制器是啟動后插入的,就不會被識別和輪詢到。
- 在進入主循環前,通過循環將
存在的問題與后續工作:
-
當前方案不能動態檢測控制器插入/拔出:
- 一旦某個控制器在啟動后才插入,將永遠不會被識別為“可用”;
- 這并不是理想做法,僅僅是權宜之計;
-
必須引入控制器連接狀態監控機制:
- 后續需要通過某種“設備變更通知”或系統消息監聽機制,實現動態檢測 Xbox 控制器的連接與斷開;
- 例如注冊系統消息,當插入或拔出設備時接收到事件通知;
- 一旦檢測到變化,就更新
xbox_controller_present
狀態,并允許再次嘗試讀取。
總結:
- 通過引入一個數組來跳過不必要的輪詢操作,顯著減少了 CPU 浪費;
- 當前代碼在程序啟動后,只允許識別已插入的控制器;
- 一旦發現某個控制器未插入,將永久停止對它的輪詢;
- 這是臨時措施,后續必須配合系統級設備監控機制來動態更新狀態;
- 現在可以通過運行程序并查看性能分析,觀察優化措施帶來的性能提升情況。
這個方案雖然不完整,但可以立刻獲得明顯的性能改進,并為將來的更完善機制做準備。
運行游戲并看到不再有控制器輪詢的問題
我們現在來看,經過前面對控制器輪詢邏輯的優化之后,性能瓶頸已經被徹底移除。程序不再陷入那種每幀無意義地調用控制器輸入獲取函數所造成的“卡頓”——因為我們設置了判斷機制,如果控制器不存在,就不會再去輪詢它。
當前狀態的好處:
- 程序運行效率大幅提升;
- 控制器未連接的情況下不再消耗高達 兩百萬 CPU 周期 去“確認”這個事實;
- 整體輸入處理流程干凈、順暢,不再出現令人費解的性能波動;
- 這正是我們想要達到的初步目標。
仍需注意的事項:
我們在代碼中已經明確寫下了“待辦項”,提醒自己未來要補全這部分功能,具體包括:
- 要在平臺層中查閱資料,研究如何在操作系統層面接收設備連接變化的通知;
- 目標是讓程序能動態識別控制器的插入與移除,及時更新那個狀態數組;
- 目前的實現,只能在程序啟動時識別已連接的控制器,無法響應運行期間的控制器變更;
- 所以這不是完整的解決方案,只是為后續開發打下良好基礎。
接下來要做的:
既然我們已經解決了控制器帶來的性能問題,現在就可以**回到 profiler(性能分析器)**的工作上了。
- 接下來要繼續推進輸入處理子模塊(input processing sub-loop)的細化分析;
- 會在 profiler 中深入展開分析子塊,定位更多潛在的性能問題;
- 目的是進一步拆解分析,提升程序中其他部分的運行效率;
- 從頂層時間塊往下挖掘,明確每一段邏輯所消耗的 CPU 時間;
- 利用這些數據指導后續優化決策。
總結:
- 成功規避了 Xbox 控制器輪詢導致的嚴重性能浪費;
- 利用了狀態標志數組防止反復執行高開銷的輸入函數;
- 明確了后續需通過操作系統通知機制實現控制器熱插拔識別;
- 現在可以將注意力轉回性能分析器,繼續深入優化輸入處理子系統。
這標志著從“修復性能大坑”階段,過渡到了“深入微調性能細節”的階段。
發現軟件渲染器沒有正確繪制性能分析器
我們現在查看性能分析器的輸出界面時,發現了一些異常現象,引發了我們的好奇和進一步排查的欲望:
當前遇到的問題:
- 在切換到軟件渲染模式(software render)之后,性能分析器的可視化數據顯示似乎不太對勁;
- 整個區域呈現出異常的全粉色塊,沒有像之前那樣正確渲染時間分布結構;
- 明明我們已經切換了渲染器,而且也能觀察到一些線程活動的跡象,比如 lane count 增加,說明后臺確實有更多線程在工作;
- 說明性能分析器仍然在記錄線程行為、數據也在更新,只是渲染結果異常。
當前判斷與疑點:
- 性能數據本身是被正確記錄的,因為 lane 數量發生了變化;
- 問題可能出在 渲染邏輯或者可視化顯示層面;
- 懷疑可能與 排序算法、渲染順序或布局邏輯 有關,某個處理步驟出現了 bug;
- 有可能是圖形繪制部分沒有正確處理軟件渲染下的數據格式或線程分布,導致全部被覆蓋成同一顏色區域;
- 還不確定是否是新加入的渲染模式與舊代碼兼容性的問題。
后續可能需要的動作:
-
深入調試性能可視化模塊,看看是否存在處理多線程數據排序、繪圖坐標計算錯誤;
-
檢查軟件渲染模式是否使用了某些和 GPU 渲染不同的線程邏輯,導致圖形標記失效;
-
確保分析器在多個渲染后端下都能正確解釋和繪制線程數據;
-
可以嘗試對這部分繪制進行“降級驗證”,例如:
- 先只顯示一個線程的數據看看是否能恢復正常;
- 關閉排序、顏色分類等輔助渲染,驗證是否是其中某個開關引起的問題;
- 對比切換前后的數據結構,看是否有缺失字段或誤差;
小結:
- 雖然我們成功記錄了線程行為并獲得了數據,但渲染結果異常;
- 目前推測是排序或顯示邏輯方面的 bug;
- 接下來需要專注調試渲染器輸出模塊,確保所有線程和渲染模式下都能正確繪制 profiler 圖形;
- 這是性能分析工具本身的關鍵穩定性問題,必須修復,以便更好地服務后續優化流程。
這個階段屬于性能工具自身的健壯性完善,而不僅是優化游戲邏輯了。
game_opengl.cpp:使 glTexImage2D 使用 GL_SRGB8_ALPHA8
我們遇到的一個問題讓我們想起了另一個潛在的渲染相關 bug,具體涉及 OpenGL 在使用軟件渲染輸出位圖時出現的圖像偏色或發白現象。以下是對問題的詳細思考和技術排查分析:
問題現象:
- 在使用軟件渲染并通過 OpenGL 顯示圖像時,畫面表現異常,呈現出偏白的色調;
- 而如果是直接使用硬件渲染(比如標準 OpenGL 路徑),這個問題則不會出現;
- 所以懷疑問題可能出現在渲染圖像傳輸到 GPU 的階段,而不是軟件渲染本身。
技術懷疑點:
- 懷疑焦點在顏色空間處理上,尤其是有關 sRGB 和線性 RGB 的設置是否一致;
- 在使用
glTexImage2D
上傳紋理時,紋理的內部格式指定為線性 RGB,也就是默認的GL_RGB8
或GL_RGBA8
; - 但根據當前幀緩沖(framebuffer)的設定,幀緩沖可能被配置為
sRGB
格式(例如GL_FRAMEBUFFER_SRGB
打開); - 若輸入紋理是線性 RGB,而幀緩沖自動執行了 gamma 變換(sRGB 解碼),就會導致畫面亮度被提升,出現明顯的偏白效果。
當前代碼路徑分析:
- 圖像上傳使用了
glTexImage2D
; - 上傳時指定的紋理格式未必是
GL_SRGB8_ALPHA8
或其他 sRGB 格式; - 如果幀緩沖啟用了
GL_FRAMEBUFFER_SRGB
,OpenGL 會在寫入幀緩沖時自動將顏色從線性空間轉換到 sRGB 空間; - 這在上傳線性 RGB 圖像時,會導致顏色被二次 gamma 校正,結果是圖像變亮。
可能的修復方向:
-
確保紋理格式和幀緩沖格式匹配:
- 如果啟用了
GL_FRAMEBUFFER_SRGB
,則應上傳GL_SRGB8_ALPHA8
等 sRGB 格式紋理; - 或者如果上傳的是線性 RGB 紋理,應禁用
GL_FRAMEBUFFER_SRGB
,防止額外的 gamma 轉換。
- 如果啟用了
-
修改紋理上傳格式:
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8_ALPHA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
-
禁用幀緩沖 sRGB:
glDisable(GL_FRAMEBUFFER_SRGB);
-
調試驗證:
- 打印當前 framebuffer 配置,確認是否開啟了
GL_FRAMEBUFFER_SRGB
; - 嘗試在軟件渲染路徑下啟用與硬件渲染一致的顏色空間設置,觀察是否能還原正確圖像色彩。
- 打印當前 framebuffer 配置,確認是否開啟了
小結:
- 當前畫面發白的核心原因極可能是 顏色空間不匹配導致的重復 gamma 校正;
- 圖像上傳和幀緩沖輸出必須在顏色空間設定上保持一致性;
- 為避免偏色,必須確認上傳的是 sRGB 格式紋理,或者關閉幀緩沖的 sRGB 轉換;
- 這是一個非常典型的圖形管線中顏色空間未對齊導致的視覺 bug,調整配置后應該能修復。
此問題處理好之后,軟件渲染和顯示邏輯才算真正收斂與一致。
GL_SRGB8_ALPHA8
和 GL_RGBA8
是 OpenGL 中兩個不同的紋理內部格式(internal format),它們的主要區別在于 顏色空間(Color Space)處理方式不同,尤其是在和 GL_FRAMEBUFFER_SRGB
搭配使用時會產生視覺和性能差異。
核心區別總結:
格式名 | 顏色空間 | 含義 | 用途 |
---|---|---|---|
GL_RGBA8 | 線性 RGB | 每個通道(R、G、B、A)都是 8 位整數,線性空間 | 常規用途,不自動進行 gamma 處理 |
GL_SRGB8_ALPHA8 | sRGB | RGB 在存儲時采用 sRGB 曲線(非線性),A 是線性 | 常用于 UI、貼圖、圖片等已在 sRGB 空間下制作的資源 |
詳細對比:
1. GL_RGBA8
(線性顏色空間)
- 使用線性 RGB 存儲顏色;
- 沒有 gamma 校正;
- 寫入到啟用了
GL_FRAMEBUFFER_SRGB
的 framebuffer 時,會自動進行 gamma 校正(線性 → sRGB); - 常用于:線性光照計算、G-buffer、計算后再展示的中間圖像。
2. GL_SRGB8_ALPHA8
(sRGB 顏色空間)
- RGB 通道使用 sRGB gamma 編碼,A 通道仍是線性的;
- 告訴 OpenGL:“這張紋理是 gamma 編碼的”,采樣時會自動解碼為線性空間;
- 寫入啟用
GL_FRAMEBUFFER_SRGB
的 framebuffer 時,不會再次 gamma 校正(避免重復); - 常用于:UI、原始貼圖(攝影圖像、UI元素、PNG資源等),它們本身就已經是 sRGB 空間的。
舉個例子:
場景:你有一張 .png
圖像,已經是 sRGB 空間。
- 推薦格式:使用
GL_SRGB8_ALPHA8
,OpenGL 在采樣時會幫你解碼為線性空間進行光照計算; - 如果你用了
GL_RGBA8
,則它會被當作線性空間來處理 → 渲染結果顏色會偏暗或不準確。
場景:你從計算中生成了一張顏色 buffer,存儲了光照值。
- 推薦格式:使用
GL_RGBA8
,因為是線性空間; - 如果你誤用了
GL_SRGB8_ALPHA8
,OpenGL 會以為它是 gamma 編碼的,會在采樣時錯誤地“解碼”。
搭配 GL_FRAMEBUFFER_SRGB
時的注意:
glEnable(GL_FRAMEBUFFER_SRGB);
- 會在**寫入 framebuffer(不是紋理上傳)**時自動執行 gamma 編碼;
- 所以你上傳的紋理必須是線性格式(如
GL_RGBA8
),否則會“重復 gamma”,導致圖像偏白。
簡潔建議:
如果你是上傳貼圖 | 如果你是上傳計算結果(線性光照圖) |
---|---|
用 GL_SRGB8_ALPHA8 | 用 GL_RGBA8 |
開啟 GL_FRAMEBUFFER_SRGB | 開啟或關閉都可(視需求) |
配圖理解(顏色空間流程圖):
|--[sRGB Texture: GL_SRGB8_ALPHA8]--(auto decode)--> Linear RGB --> Shader Work
Resource --||--[Linear Texture: GL_RGBA8]-----------------------> Linear RGB --> Shader Work
Shader Output --> Linear RGB --(GL_FRAMEBUFFER_SRGB on)--> sRGB --> Screen
畫面上沒看出什么區別
運行游戲并看到問題已經解決
我們之前懷疑渲染異常可能是因為沒有使用 sRGB 格式讀取紋理,結果確實如此——問題正是出在這里。現在我們修復了它,渲染效果就恢復正常了,非常好。這樣我們現在可以在軟件渲染和硬件渲染之間自由切換,并且最終的視覺效果幾乎完全一致。
這其實還挺有意思的,雖然是相同的內容,在兩個渲染路徑下的默認渲染效果卻略有不同。我們猜測可能是由于像素中心坐標不夠精確導致的渲染差異,這是目前最有可能的原因。雖然不確定,但除此之外也想不到別的解釋了。
不過這項修復本身還是很酷的,現在切換渲染路徑不會再帶來明顯的視覺差別,這是一個非常理想的狀態。
另外還有一個小插曲:我們看到問題的時候突然想到這個可能的原因,當時就有點沖動地想去驗證,結果還真找到了問題的根源。這種偶然之間發現問題然后迅速定位修復的感覺還是挺令人滿意的。
解釋渲染器中的伽瑪處理
在進行伽馬校正時,我們渲染的過程是先加載圖像并進行伽馬空間到線性空間的映射,接著進行所有的混合操作,最后將結果從線性空間映射回伽馬編碼空間。這樣做是為了確保顏色的正確顯示,不會因為計算中的顏色空間不一致而導致問題。
問題發生在我們使用 OpenGL 顯示軟件渲染結果時。具體來說,當我們將軟件渲染的結果(這些結果是伽馬編碼的)提交給 OpenGL 時,我們沒有告訴 OpenGL 這些紋理是伽馬編碼的。因此,當 OpenGL 渲染圖像并寫入幀緩沖時,它錯誤地認為這些紋理是線性空間的值。然后它將再次應用伽馬曲線,而實際上它應該先將紋理從伽馬編碼空間轉換為線性空間。
這就導致了一個問題:OpenGL 在寫入幀緩沖時應用了錯誤的伽馬曲線,結果是顏色變得異常亮,因為它錯誤地將已經經過伽馬編碼的顏色再次進行了伽馬映射。簡而言之,我們只做了伽馬映射的一半過程,導致了圖像看起來過于明亮。
通過解決這個問題,我們確保了伽馬校正的過程正確執行,避免了顏色渲染上的異常。
注意到我們需要在啟動時保留一個紋理,以防以后需要做 OpenGL 位圖顯示
在切換到硬件渲染時,之前我們已經為游戲分配了一些紋理。但現在由于我們切換到 OpenGL 渲染,不能再假設我們能夠繼續使用紋理1。為了避免這個問題,需要做一些調整。具體來說,我們應該在程序啟動時預留一個紋理,專門用于 OpenGL 位圖顯示。
為了實現這個目標,我們計劃創建一個全局的紋理句柄,用于這次特殊的渲染操作。這樣,當我們切換渲染模式時,可以確保我們有一個專用的紋理用于顯示,不會與其他紋理發生沖突。
此外,還注意到了一些與紋理相關的 bug,可能是因為在切換時未能正確處理紋理的分配和切換,導致了渲染錯誤。解決這一問題的方式是確保在渲染過程中紋理的分配和切換得到適當的管理。
演示在渲染器之間切換時的 bug
在切換渲染模式時,出現了一個問題。當從硬件渲染切換到軟件渲染時,背景圖層顯示異常。具體來說,在軟件渲染時,會看到一個奇怪的背景,這個背景實際上是軟件渲染覆蓋了原本為硬件渲染準備的紋理。這個問題發生的原因是,軟件渲染修改了之前用于硬件渲染的紋理,導致背景圖層出現了異常圖像。
為了解決這個問題,需要確保為背景渲染使用一個單獨的紋理。這就意味著,在切換渲染模式時,不能修改同一個紋理,而是要為軟件渲染和硬件渲染分別分配不同的紋理。這樣,就能夠避免在切換渲染模式時相互覆蓋,保證渲染的正確性。
win32_game.cpp:引入 OpenGLReservedBlitTexture,并在 Win32InitOpenGL 中設置其中一個
為了避免硬件渲染和軟件渲染之間的紋理沖突,需要在運行時從圖形硬件中分配一個新的紋理,并為其分配一個句柄。這使得在切換渲染模式時,不會覆蓋或修改現有的紋理,從而保證渲染的正確性。
具體步驟包括:
- 為紋理分配一個句柄,并確保在進行 OpenGL 渲染時,使用這個專門分配的紋理。
- 在 OpenGL 初始化完成后,調用
genTextures
來生成一個新的紋理,并將其傳遞給 OpenGL 的顯示函數。 - 確保在進行
openGL display bitmap
時,綁定這個紋理,以確保不會覆蓋已經存在的紋理。
這樣,通過保證在渲染時使用獨立的紋理,可以有效避免在切換渲染模式時出現圖像覆蓋或異常的問題。
運行游戲并看到現在沒有那個 bug,但字體的像素中心出現了問題
現在,使用了一個保留的句柄,這樣就確保了這個句柄不會被其他部分的代碼使用,從而避免了紋理覆蓋的問題。通過這種方式,軟件渲染和硬件渲染之間的切換不再出現之前的錯誤。現在切換渲染模式時,一切看起來正常了。
雖然軟件渲染的像素中心(pixel centers)可能還存在一些瑕疵,但這部分的優化可能會等到游戲開發的后期進行,因為目前軟件渲染主要是為了教學和演示其原理,實際游戲中對其要求并不高。為了進一步提升軟件渲染的質量,未來可以回去解決像素中心的問題,這實際上并不困難,只需要更多的數學計算和處理。
目前,大部分的開發任務已經完成,所有的主要問題都得到了解決。接下來,計劃繼續改進性能分析工具,讓它更強大,可以支持更方便的層次結構導航和顯示。整體來說,項目進展順利,接下來的任務已經明確,團隊已經準備好繼續推進。
在處理完這些后,可以稍作休息,準備迎接下一個開發階段。
Q&A
問:不記得 OpenGL 認為像素的中心在哪里了…?
在討論OpenGL的像素中心時,OpenGL選擇了一種特殊的方式來處理像素坐標。OpenGL定義像素中心的方式是,當指定紋理的左下角為(0, 0)和右上角為(1, 1)時,紋理坐標與像素坐標是嚴格對齊的。這意味著,如果你用0,0和1,1的紋理坐標來繪制圖像,OpenGL會將紋理精確地映射到屏幕上,以保證每個像素的中心對齊,而不會產生模糊或錯位。
這與早期的3D圖形標準有所不同,3D圖形最初的像素定義方式并沒有考慮到這種對齊問題,但后來做了修正。這種做法被認為非常智能,因為它確保了雙線性過濾和紋理映射中的像素覆蓋能夠完美匹配,避免了可能的顯示問題。通過這種方式,OpenGL能夠實現更加準確的像素渲染和紋理過濾。雖然這種做法可能不是每種渲染引擎的最佳選擇,但對于OpenGL來說,這是一個非常合理的定義方式。
能否進一步闡述性能分析器中等待 VSync 的問題?
在討論“等待垂直同步”(v-sync)時,實際上指的是GPU告訴你是否可以繼續提交新的渲染幀。具體來說,等待v-sync并不一定意味著與垂直同步(即顯示器刷新率)的實際時刻完全對齊,它更多的是指GPU已經完成了當前的渲染工作,并且需要等待合適的時機來顯示下一幀。
當調用“swap buffers”時,GPU可能會立即返回,表示可以提交新的渲染工作,即便當前幀還沒有完全顯示完畢。但如果GPU還沒有完成上一幀的渲染,它就會告訴你等待,直到它完成當前的顯示任務,才能繼續提交新的內容。這種機制確保不會提交過多的渲染任務,從而防止GPU過載。
因此,調用“swap buffers”時并不意味著立即等待垂直同步的發生,而是告訴GPU“這是當前的渲染命令,請在適當的時機展示它”。如果GPU進度太快,可能會把當前線程掛起,直到它準備好接收下一幀。這就導致了“等待垂直同步”并不總是立即發生,可能會存在一定的延遲,而這種延遲并不是直接與垂直同步時間對齊的。
總的來說,等待v-sync的過程涉及到GPU的內部調度,它確保不會過度提交渲染任務,避免了幀率過高而導致的性能浪費或畫面撕裂。
我其實更喜歡軟件渲染器產生的字體。我們能做點什么讓它們在 GL 中看起來一樣嗎?
對于軟件渲染所產生的錯誤效果,可能的原因之一是軟件渲染使用的伽瑪曲線是近似的,而硬件渲染使用的是更加精確的伽瑪曲線。特別是透明度(alpha)的處理,可能也會對渲染結果產生影響。軟件渲染的近似伽瑪曲線可能導致某些顏色或亮度效果不同。
要解決這個問題,可能需要重新調整資產打包器中的處理方式,確保采用正確的sRGB伽瑪曲線。這將有助于讓硬件渲染的效果更接近軟件渲染的效果。因此,可能需要進一步檢查和調整這些參數,以便在硬件渲染中獲得更理想的結果。
推薦給有志于成為游戲開發者的入門級編程語言?
對于編程語言的選擇,認為入門編程時使用像JavaScript這樣的語言并不會讓人成為糟糕的程序員,實際上是可以接受的。編程的關鍵在于大量的實踐,而不在于使用什么語言。很多人一開始學習編程時,并不會直接學習像C或匯編這樣的底層語言,而是會從簡單的語言入手,比如BASIC或者Logo等,重要的是開始動手做,而不是選擇某個特定的語言。
然而,問題出在如今的技術環境中,很多人開始學習編程時,會停留在像JavaScript這樣的高層語言上,而不再有動力去學習底層的編程知識。這就意味著,他們可能會繼續在這些高級語言中編寫性能不高、效率低下的程序,盡管這些程序仍然可以發布和使用,且沒有比一些大型公司(例如Google)所發布的程序差。這種現象雖然可以讓開發者在短期內通過高層語言做出競爭力的應用,但如果不學習更底層的控制,如內存管理和指針操作,程序的質量可能會受到影響。
長期來看,這種情況并不利于技術的發展,因為它減少了人們向更高效編程語言轉變的動力,導致一些開發者可能永遠不會接觸更底層的語言,如C語言。盡管目前的高級語言很強大,但仍然無法完全抽象出所有低級操作。若不學習底層語言,最終可能會讓程序變得不夠精確和高效。
盡管如此,入門時使用任何語言都是合適的,只要它能讓學習者感興趣并且迅速上手。但如果要認真做編程,特別是想成為一名優秀的軟件工程師,就應該逐步過渡到更底層的語言,學習如何控制內存、使用指針等。這是一個重要的成長步驟,而不單單依賴于高級語言的便利性。
有沒有什么可以幫助我們更容易懸停/點擊調試可視化的辦法?
為了簡化在剖析器(profiler)中懸停點擊(hover click)操作,解決方案是通過設置一個“暫停”狀態來實現。當系統處于暫停狀態時,它將停止收集新的時間信息,這樣就可以方便地在不同的幀之間進行滾動,并準確找到需要查看的幀進行懸停操作。
實現的方法是通過在調試狀態中設置“暫停”標志,使得在調試過程中,程序不會繼續執行新的操作或收集數據,這樣就可以在查看每一幀時,輕松暫停并進行精確操作。
通過這種方式,調試和分析過程中的交互將更加簡便,不會因為數據持續更新而使得懸停操作變得困難。這也解決了在查看和比較不同幀時可能遇到的問題。
每幀都會傳輸所有紋理嗎?
并非每一幀都轉移所有紋理。只有那些剛剛從磁盤加載的紋理會被轉移。實際上,OpenGL 并不會在每一幀都概念性地傳輸所有紋理。紋理的傳輸是在需要時進行的,通常是在第一次加載紋理時進行。
在紋理加載系統中,每當從磁盤加載完紋理后,程序會立即要求平臺層將其下載到內存。這一過程是由后臺線程處理的,因此紋理一旦下載完成,它就會保存在內存中,不會重復加載。
例如,在渲染一個復雜場景時,可能會涉及大量的紋理數據。一些場景中,可能有五六個甚至更多的紋理需要加載。最初,在紋理加載的第一幀中,可能會出現卡頓現象,因為所有紋理都是在這一幀被下載的。但目前,所有的紋理下載工作都是在后臺完成的,因此可以實現更平滑的渲染體驗。
當場景播放時,程序會提前加載并下載下一批需要的紋理,這樣它們可以在合適的時機被使用,實現了按需流式加載的效果,從而避免了明顯的性能問題或卡頓現象。
在調試 UI 中,我們能點擊某一幀并將其復制到屏幕上嗎?
不能實現點擊幀并將其顯示出來的功能,因為要做到這一點,我們需要每一幀都從幀緩沖區讀取并保存。這個操作顯然是不可行的,這相當于我們需要編寫自己的幀記錄器,這雖然是一個有趣的練習,但并不適合在這個環境中使用。實際上,是否真的需要這個功能也是值得考慮的。
能否解釋一下動態分辨率是如何工作的?是紋理重縮放、視口重縮放嗎?
目前我們并沒有處理動態分辨率的功能。現在我們只是一直以 1080p 的分辨率渲染并顯示到屏幕上。動態分辨率的調整將會在接近發布時進一步考慮和優化。
我們會有自動暫停在長幀時的功能嗎?
不會自動暫停長幀。雖然可能會有一些功能,幫助我們更容易地看到長幀的存在,但不會自動暫停或跳轉到長幀的處理。
檢查內存泄漏怎么辦?
關于內存泄漏的檢查,雖然最終我們會進行一些檢查,但目前并不認為它會成為我們游戲中的一個大問題。我們并不認為內存泄漏會在當前的上下文中引發嚴重問題,因此它不是我們優先考慮的事項。雖然我們可能會做一些基本的檢查,但這不是當前的重點。
什么時候應該從像 JavaScript 這樣的基礎語言轉向像 C 這樣的語言?
在學習編程的過程中,應該在掌握了基礎的編程概念之后,逐步過渡到更復雜的語言,如C語言。首先,當你已經能夠理解JavaScript中如if
語句、while
循環等基本結構,并且能夠編寫結構清晰的程序時,就可以考慮轉向C語言。這是因為C語言相比于JavaScript,需要你自己管理內存并理解程序的內存布局,增加了編程的復雜性。
從簡單語言開始學習編程的目的是為了讓你能夠掌握編程的基本構建塊,比如控制流、函數、返回值等,而不用擔心復雜的底層細節。這樣做就像是騎自行車時戴上了輔助輪,能讓你更專注于理解編程的核心概念,而不用一開始就面對所有的技術難題。
一旦你熟悉了這些基本的編程概念,就可以轉向更復雜的語言,并開始學習如何控制內存、如何與系統交互等更底層的內容。重要的是,不必成為JavaScript的專家,只要通過它掌握編程的基本原理,然后繼續向更深層次的語言過渡。
然而,JavaScript有一個缺點,即它是動態類型的,這意味著程序中的類型錯誤可能不會被及時捕捉到,而是在運行時才暴露出來。而像C語言這樣的靜態類型語言,會在編譯階段就發現類型錯誤,這有助于更早地捕獲潛在的bug。
總結來說,學習編程的過程中可以從簡單的語言入手,掌握基礎的編程技能,再逐步轉向更復雜的語言。JavaScript雖然適合入門,但它的動態類型機制可能會給編程初學者帶來一些困擾,因此在深入學習后,學習一門靜態類型的語言,如C語言,是非常有幫助的。
抱歉,有個故障保護來判斷什么時候我們卡頓并暫停系統來調試
在調試過程中,如果遇到卡頓(stutter),不能立即暫停并回到卡頓的幀進行調試。因為一旦發生卡頓,程序的狀態已經向前推進,所以無法直接跳回到卡頓的幀進行調試。為了查看卡頓的原因,唯一能做的就是查看調試輸出和調試流數據。由于這些數據會持續記錄,所以可以在稍后暫停并回溯查看。
雖然可以手動暫停,回溯并檢查卡頓幀,但不建議將這個過程自動化,因為這樣會影響正在進行的其他操作,造成干擾。自動暫停會導致卡頓事件頻繁發生,影響調試流程。因此,除非進行大量的額外工作,無法直接回溯到卡頓幀進行調試,也不建議自動暫停。