OpenShader月報 #0

雖然沒有做paperwork的習慣,但我覺得在這時候還是寫一篇總結這個月工作進展的文章比較好,畢竟衛星放出來如果沒時不時地搞點大新聞的話總會讓人覺得棄坑了是吧 233

  • RenderChunk更新優化
    眾所周知MC的地圖是以16x256x16的Chunk - 即所謂的區塊 - 為存儲單位,但在渲染時是以16x16x16的RenderChunk為單位進行更新和渲染,問題在於RenderChunk的更新並不是很快,在i5-4590的機器上大概需要6ms每單位,雖然MC已經引入了多線程更新RenderChunk的設計,但這隻局限於由地圖加載導致的更新,由磚塊變化產生的更新依然要求單線程更新,這就導致了當發生大規模的磚塊更新時遊戲幀率會驟然下降,比如大規模流水或接入高頻紅石電路的紅石燈等.
    RenderChunk的更新操作實際上是遍歷區塊中16x16x16的部分逐磚塊地進行可見性判斷,然後將磚塊的模型面填充入頂點緩衝中,其中獲取光照度和獲取磚塊的IBlockState這兩步操作開銷莫名的大,而在原版的更新流程中它們的調用又十分頻繁,因此在此做一個簡單的緩存就能戲劇性地加快原版更新速度.
    然而Forge還增加了它自己的更新流程,被稱為ForgeLightPileline,顧名思義,它是解決在複雜模型下原版光照錯誤的問題,在默認情況下它是取代原版流程的,它的速度跟未經優化的原版差不多,然而它的可優化空間實在不大,一堆堆的三維數組像節日綵球一樣在程序里傳來傳去還要不停地拆包打包可真沒有什麼優化手段,但是沒有它又還真不行,因此這裡採用的措施是增加一個判斷器根據被渲染的磚塊的模型類型來判斷該採用哪個更新流程,毫無疑問這一步並不快,因此加入了一個LUT緩存,採用打表查表的方式來加速判斷.
    此外還有一個被臨時取消掉了的優化,是讓上文提到的發生變化的磚塊也進行多線程更新,這一項優化效果斐然,但是卻存在兩個巨大的缺陷,一個是在首次進入遊戲時有大概5%的概率因為某個競態條件而陷入卡死 - 提交入隊列的更新任務莫名其妙地沒有被執行,這個問題我調查了幾天也沒有結果;如果說它可以通過一個條件變量強制在首次更新時採用單線程來"解決"的話,下一個問題根本無法繞過,在更新區塊時,某些BlockRenderLayer的變化要到下一幀才會反饋,首先要解釋一下BlockRenderLayer(以下簡稱layer),從1.8開始MC中的磚塊被區分為4種layer: SOLID(無Alpha測試,有Mipmap)、CUTOUT_MIPPED(有Alpha測試,有Mipmap)、CUTOUT(有Alpha測試,無Mipmap)和TRANSLUCENT(開啟混合). 在渲染時MC會將按照這4個layer逐層渲染磚塊,原因很顯然,不同的layer需要不同的渲染狀態.而在引入多線程更新後遇到的問題就是,當區塊發生更新時,某些layer中的頂點變化總會慢一幀才能表現出來.
    比如,草地的layer是CUTOUT_MIPPED,石頭和泥土地是SOLID,當你敲碎一個放置在草地旁的石頭後,石頭破碎的地方不會被填上草地,而且會露出一個空洞,這個空洞直到下一幀時才會補上,然而奇怪的是設斷點會發現更新只發生了一次並且是發生在敲碎石頭的那一幀,這難以解釋為何一次更新的效果直到下一幀才會生效,在更新完畢後將數據從客戶端(CPU/內存)上傳到服務器(GPU/顯存)的操作無論是單線程版本還是多線程版本都會被留在主線程進行,或許是由於內存可見性什麼的問題,但不管怎麼說,具體原因始終未能確定,因此只能暫時取消掉多線程更新這個優化.

    圖:在擊碎磚塊的瞬間留下的空洞,這個空洞在下一幀即被填上了
  • 自定義磚塊貼圖
    Shadersmod增加了法線貼圖"_N"和高光貼圖"_S"兩種額外的磚塊貼圖,現在OpenShader也完成了新增磚塊貼圖的功能了,開發者可以自定義要載入的貼圖的後綴名以及當無貼圖時的默認值,比如法線的默認值可以採用0xFF7F7FFF(BGRA格式的(1, 0.5, 0.5, 1)). 不過顯然在切換光影包時重新載入一遍貼圖會很慢,因此Mod提供了一個選項是提前緩存_N和_S兩種後綴的貼圖並且在切換光影包時不卸載它們.
  • 界面&選項
    現在已經有了一個類似Shadersmod那樣的光影包菜單,並且實現了切換或重載,理論上講Mod現在已經可以正常使用了.
  • 多線程渲染準備
    在進行正式渲染之前,MC需要先準備渲染數據,比如計算鏡頭數據、對場景做視錐裁剪等,這一部分在之前是單線程完成的,現在這一步是以Pass為單位進行多線程處理,當Pass數量小於等於CPU核數時,渲染準備所需的時間就從"所有Pass準備時間之和"變成了"最慢的Pass的準備時間",也算是省出了一些時間吧...

正在進行的工作:

  • 池化VBO&MultiDraw (60%)
    當我在測試用的光影包中嘗試實現一個樸素三重CSM(不根據視錐做偏移,陰影鏡頭始終對準玩家)時發現性能急劇下降,從Profiler的結果來看一個問題是最大視距的那個陰影鏡頭所需要的準備時間太長了,另一個問題是磚塊繪製過慢,在10視距時,玩家視角與3個ShadowMapping過程總共需要繪製1000+次RenderChunk,繪製一個RenderChunk時還要為每個layer綁定一次VAO再做一次Drawcall,這樣算下來每幀會需要數千次Drawcall和等量的VAO綁定,性能瓶頸妥妥地是在驅動上,事實上確實當顯卡佔用沒增加多少而CPU卻有一個核心已經跑滿了. 因此問題在於如何降低調用數量,N卡有個BindlessMultiDrawIndirect,可以在不綁定VAO/VBO的情況下進行單指令多渲染,但顯然我們不能指望一個供應商獨佔擴展...因此我把注意力集中到了最簡單的MultiDraw上,MultiDraw是個很早的特性,我記得好像是GL1.5時代就有了...它可以一次指令繪製一個幾何體中多個不連續的部分,而缺點是中途不能切換紋理等,顯然這對MC來說不是問題,關鍵在於如何將多個RenderChunk中的幾個layer的頂點數據都整個在一個VBO中.
    我的解決方案是以4x16x4個RenderChunk為一組(也就是覆蓋4x4個Chunk)使用一個手動維護的內存池來共同存儲所有layer的頂點數據,這個內存池的實體是在顯卡顯存中的一個VBO,在客戶端程序會負責空間分配以及數據傳輸等工作,當RenderChunk向內存池首次提交一次數據更新時,內存池會根據數據體積分配一段略大於數據體積的空間,然後返回給提交者一個指針(有意思的是,這個指針是可變值(Mutable),我知道一個可變值指針聽上去即不可思議又噁心,但這確實能省下不少事),同時會在一個SortedSet中記錄已分配的指針,有個例外是空數據提交產生的指針,這種指針不會被插入到那個SortedSet中.
    每一個內存池都要面臨的問題是碎片整理和擴容,理論上這個內存池可以通過glCopySubBuffer來整理碎片,只要將末尾的內存複製到前面的空隙即可(glCopySubBuffer有個特性是複製源範圍和目標範圍不能重合,這導致了複製空隙緊後面的數據到前面來,以"冒泡"的形式整理碎片的方法不能用了),但我暫時還沒實現,現在的做法是在擴容的同時順便進行碎片整理,glBufferData在擴容的時候不會保留舊的數據,因此得先分配一個新的VBO,然後把數據複製過去,再銷毀舊的.在複製時如果遇到空隙的話就會跳過去,同時糾正之後的指針的偏移量(這也是為什麼它是可變值)
  • 指令隊列 (30%)
    鑒於OpenGL要求必須在主線程渲染的特點,壓榨性能的方式之一就是將渲染數據的整理工作通過多線程來處理,主線程只負責指令提交工作,指令隊列響應了這一思想,所有渲染操作都被分類為一些指令,一個阻塞隊列負責緩存這些指令,協線程負責收集數據和生成渲染指令,並將指令送入隊列,主線程從隊列取出指令並執行,這應該可以一定程度上提高性能,畢竟擁有更高的並行度,並且主線程執行的工作更單一,不會受CPU緩存污染的困擾. 現在,唯一的問題在於,從隊列中讀取指令並執行的開銷會抵消這些提升嗎...
  • Early-Clear (0% :P)
    上文提到了渲染和數據準備是分開的,但事實上有一種渲染操作"基本"無需數據 - 清除緩衝,說它"基本"是因為主視角的清除顏色緩衝還依賴於霧顏色,而霧顏色的計算是在相機計算中進行的,因此可以在完成相機計算後主線程提前開始為各個Pass進行緩衝清除,而協線程繼續準備其餘的數據.
  • 很...很多... (紫asdfghjkqwertyuiozxcvbnm%)
    顯然今年沒Demo了! 看看明年1月衛星能不能落地吧! 新年快樂 🙂