1. 程式人生 > >聊一聊2D地圖的迷霧效果

聊一聊2D地圖的迷霧效果

png char 一段 行數據 編程經驗 lan get 第一個 色值

在經歷了17年諸般變動後,現在到了2018年。懷舊是因為之前幾年發生太多,好與不好都已過去,17年迎來新的開始,所以也就有了後來些許感慨,有機會再說說這些行業感受了。今天先讓我們專註於川最新解決的實際項目問題。

戰爭迷霧是很多帶地圖的遊戲多少會考慮的一個功能。恰巧川在17年下半年開始做的項目也涉及到了這次的內容,而且在開始審題時,這簡直是送分題啊!我們先來看看題。

“今有2D地圖一張,需制作戰爭迷霧,地圖隨角色坐標與視野範圍開迷霧,且被開的迷霧常亮。求實現功能。”

前人的實現:

在正式解決題目之前,這項內容本是組內一名同事先期在做的。他的思路通過動態修改迷霧圖的alpha值,達到迷霧被打開的效果。具體流程可以概括為:

創建mask圖->存儲地圖alpha數據到數組->遍歷更新更新被開視野的數組數據->逐像素遍歷更新mask圖像素點的alpha值->刷新圖片並應用

其實這就是為了實現功能而寫的一段代碼,實現了我們的命題,但是在擴展性以及性能方面沒有絲毫值得借鑒的地方。倒是表現效果起碼能讓人在看過之後知道是怎麽回事。

重新設計:

好吧,所謂前人的實現其實就是吐個槽,現在讓我們開始改進。我們先借鑒利用二維數組存儲地圖數據的做法,根據地圖尺寸創建一個m*n的數組,每個數據表示1平方米範圍的迷霧alpha值。根據我們的測試,居然需要消耗10-30ms!這在遊戲上是絕對無法容忍的。因此就需要圍繞節省運算力著手。

根據編程經驗,首先出問題的肯定是那個“遍歷更新更新被開視野的數組數據”和“逐像素遍歷更新mask圖像素點的alpha值”,因為單單一步就需要遍歷m*n次。假如地圖是一個1000*1000的,那麽每一幀就要遍歷兩百萬次,太恐怖了!所以,根據題目的要求,我們完全可以只更新每一幀需要更新的區域,也就是角色視野範圍大小的數據就可以。為此我們需要擁有一個Dictionary,專門存儲不同視野值的視野範圍數組,用於更新存儲地圖數據的二維數組。

在進行了上一步的優化之後,如果視野值為v,則我們每一幀只需要進行兩次v*v的遍歷就可以更新地圖數據,並把更新後的數據寫入到mask圖中。這樣我們的每一幀消耗的時間就已經控制在2-4ms。然後為了節約數據的存儲空間,我們可以設置最大邊長,然後等比換算,使這部分的存儲不大量占用內存,唯一的缺陷就是當地圖變大的時候,迷霧的分辨率會降低,不過目前來看已經解決問題了,不是嗎?

問題出現:

然而,問題是沒有真正解決那一日的,隨著代碼被創建,熵是只增不減滴!在探索mask圖尺寸臨界值的過程中會發現,mask圖本身在變大的過程中,設置像素點的值依然不是性能瓶頸,取而代之的是刷新這張圖。在Unity中,對於Texture2D的Apply代價是很高昂的,而且設置點越多、圖片越大,消耗也就越大,這必然限制我們的執行效率與展示效果。

問題解決方案:

為了解決這個問題,川祭出了大學老師教給的“分治法”!既然一張大圖的更新耗時,那麽拆分為多個小圖不就可以壓縮這部分的運算量,達到追求效率的目的嗎?說不準還能提升精度呢!於是說幹就幹。

這一解決方案的核心就是通過多張小圖組成一整張mask圖。為了實現這個效果,首先我們需要著手自己寫寫shader了。我們需要對迷霧采用自定義的shader,目的就是通過我們輸入屏幕坐標與範圍,令圖片展示這一部分的迷霧,而不必對整張圖片進行位置變換。在此基礎上,通過把那張mask小圖作為參數傳入shader中,就可以實現迷霧的效果,從而不必使用大圖來表示mask。

之後就是如何定義這些mask小圖了。為了實現mask圖切換的連貫,我們必然需要將mask圖相互存在重疊,而且這部分重疊的區域必須滿足一個屏幕所能展示的區域大小,即:一張mask小圖與它左上角的mask小圖的重疊部分的尺寸等於屏幕展示區域對應的數據尺寸。不難想象這樣做其實是犧牲空間換取時間,在沒有想到後面更好的解決方案前,這個解決方案是符合時間與空間的關系的。

根據新的方案,我們就可以每次只更新需要更新的mask小圖,這樣比更新一張大圖要來的實惠。只是麻煩的點在於需要找到一個比例,令小圖之間的重疊所占小圖的比例不能太高,否則將創建太多的mask小圖,反而得不償失。

新問題出現:

雖然第一個問題解決了,但是很快又有新的問題出現了。

當地圖的範圍再大一些的時候,我們將會不得不創建更多的細碎圖片來組成這張大地圖的迷霧圖,而且當圖的數量上去了,那麽計算量、硬件使用占用率都會提高,甚至相去我們需要解決的問題更遠。於是,這就代表上一個解決問題的思路已經行不通了,必須考慮一些新的辦法來解決問題了。

“節省空間方面無計可施時,從代碼中剝離並退回起點集中心力研究數據,常常能有奇效。數據的表示形式是程序設計的根本”引自某位國外大牛(很抱歉我忘了出處了,但是這句話我記得很深刻)。不過在啟動改進時我並沒有想起這句話,但是在遇到現在這樣的問題的時候,關於迷霧的數據表述確實是我們尋求新的解決思路最大的障礙。

其實最開始我就曾想過是否有一種可行性,即在一張圖表示屏幕迷霧遮罩圖的基礎上,每一幀只改變發生變動的像素點的色值。現在索性按照這個思路往下走對問題解決方案進行重構。

重構方案:

如果比較出每一幀變化的像素點,則必須有一份對應的像素數據的存儲結構。在這裏,我依舊使用二維數組表示整圖的迷霧數據。但是為了每一幀只改變視界範圍的迷霧遮罩圖(準確說是因為現在僅有的一張圖只表示了視界的迷霧信息),所以我們需要有一個小號的二維數組表示視界的迷霧數據。這樣當每一幀需要刷新時:首先刷新總圖迷霧數據,之後將視界迷霧數據與上一幀緩存的視界數據進行比對,將變更的位置進行記錄,並刷新視界迷霧數據,再之後根據記錄的變更信息投射到迷霧的遮罩圖上,實現迷霧遮罩圖的更新。

技術分享圖片

流程如上圖。這樣的流程走下來,相比較之前的方案,我們省去了創建多張圖的額外開銷,記錄信息變更位置並更新迷霧圖使得對紋理的操作得到大幅度的減少,僅這兩項就為我們節省了不少資源。不過,性能這個東西怎麽優化都不為過,因而我們還將繼續對方法進行改進。

優化方案1:

其實重新審視新方案,有一項最為致命的性能消耗,就是集中在了對全局迷霧進行數據存儲的二維數組。而且這裏還有一個潛藏的風險點,就是當我們的地圖數據變得更大的時候,這個二維數組也將變大,最終是否會吃爆內存也是個未知數。但是這部分其實我們是可以優化掉的,畢竟對我們的需求來說,迷霧只分為打開和未打開兩種狀態,所以一個bit就可以搞定的,那我們何必再采取其他形式存儲呢,畢竟一個bool或者一個char也有8位出去了?所以第一步就是精簡數據存儲所占用的空間。

縱觀所有基礎的數據形式,最為合適的就是64位的ulong類型。這是因為ulong類型的64位正好可以用來代表8*8的方塊數據且不會造成數據的浪費,而如果選取32bit或8bit則無法達到100%利用bit位的效果。當然如果只是為了不浪費地利用數據,那麽選取16bit也是可以滿足效果的,可是這樣造成的問題就在於我們創造的存儲16bit的二維數組的尺寸將變大。在這一點上,我們在吃過for循環處理像素數據導致時間成本變大的虧之後,選擇64bit而不用16bit就不言而喻了。

在選取好數據的存儲形式之後,我們就可以開始對原有地圖迷霧數據和視界的迷霧數據進行壓縮。在采用64bit的二維數組表示每個點的迷霧數據之後,相較我們早期使用bool二維數組,內存的占用率前者是後者的1/512(別問我怎麽算的,用64*8試試)。其實出現這麽大的優化還是很讓人汗顏的,因為這說明不是我們優化的好,而是前期設計還是太草率,沒有分析清楚問題的癥結所在。還好現在也不晚,所以繼續進行性能優化才是上策。

優化方案2:

在優化方案1之後,我們就可以繼續進行優化。這時候雖然每一步的運算時間已經維持在了個位數的毫秒階段,但是問題還是很突出。現在的問題就集中在了“記錄每一幀的視界迷霧數據變動信息”這裏。

先說說現在這裏出現的問題吧:因為在壓縮數據存儲之後並未改動其他操作,因而這一部分依舊使用老方法,即:逐點判斷對應像素區域是否有數據變化。也就是說,如果我們是視界是通過500*400的二位數組代表視界區域的所有像素點,那麽我們每一幀需要循環進行500*400次逐個去判斷這一點是否有迷霧變化。

問題找到,那麽解決方法呢?既然我們都把數據壓縮了,那麽為什麽不把數據比較也進行壓縮,然後整塊進行比較呢?畢竟兩個64bit值判斷是否相等就可以代替64次點位數據的判斷,這還是很劃算的。但是這就遇到一個問題,就是我們的視界畢竟是會變動的,無法保證視界每一次的變動,它的邊界都處在64bit數據代表的地圖邊界,必然會出現頂點在64bit方塊內的情況。其實這是我鉆牛角尖而產生的一個問題。如果說我們還需要將數據投射到紋理上,而且渲染所使用的shader也需要自己實現,那麽幹嘛不把這張紋理也做成有一定冗余,通過調整shader內的值來改變顯示範圍呢?所以這麽一想,我們依舊可以采取整塊比較的方法,無非在每一幀多四次基本運算求出遮罩紋理在四個邊的冗余百分比即可。

通過這一次的優化,我們將視界數據的比較運算整體性能提升了64倍(1次運算代替原來的64次循環,可不提升這麽多)。不管汗顏還是驕傲,總之有一個坑暫時被填平了(我也不知道為什麽用暫時,也許我還想著再壓榨一下性能,找個更好的辦法?)。不過舊坑平了,原來的一些小坑就成為了新的大坑(相對論)。

優化方案3:

現在的問題又回到了更新紋理上。在方案2的作用下,數據壓縮帶來的是性能的提升,那麽對於每一幀在視界內迷霧數據發生變更的點,又有什麽辦法呢?先說問題吧:雖然記錄了發生數據變更的點,但是對這些點進行遍歷並更新至紋理同樣會有性能消耗,隨著點的數量的不穩定,紋理更新這一整塊代碼的效率就也存在不穩定。

其實通過對Unity操作紋理的API的分析,這一部分的性能消耗依舊是集中在了數據的數量。即便減少了每一幀需要處理的點的數量(除了永遠的第一幀,這部分優化掉的計算其實還是很多的),像素點的數量依舊很大。可是這些點已經很難精簡了,所以只能考慮通過其他API,減少對紋理的操作次數。所以我們看到了API:Texture2D.SetPixels。

所以我們的解決方案就是在每增加一個數據點的統計的時候,對這個點進行歸類,歸類的依據就是像素點的橫縱坐標。通過先期對同一x坐標下的y坐標連續的點進行數據合並,然後將合並後的數據統一發給紋理進行設置,可以減少設置點的操作次數。並由於設置一個像素點和設置一串連續的像素點所消耗的時間相差無幾,這樣做的明顯好處就是為這一步的運算減少了很多運算時間。不過由於數量不固定,所以這樣優化掉的時間並沒有一個固定比例。僅以川的項目為例,在對視界範圍為666*750、具體遮罩圖尺寸為909*1024的紋理下進行像素更新,每一幀的更新時間由之前的平均4-7ms優化到了現在的0.2-0.6ms上下。這個進步還是很喜人的。

所以,川的新方案其實是從三方面著手,即對整圖數據、視界數據、紋理材質三個環節進行性能優化。與其說性能優化,不如說其實都是在設計川能想到的最優的解決方案。

讓人欣慰的是,整體流程在進行優化重新設計之後,以一張1000*800的世界地圖下為例:

1.空間性能消耗由之前16張512*512的紋理圖和6.1M的內存,減少到現在的僅用一張909*1024的紋理圖搭配97K內存。

2.時間性能消耗由之前平均一幀10ms以上,減少到現在平均一幀0.8-1.2ms。

3.效果表現上,因為地圖數據大小一致,這一部分本差不多,但是當擺脫了存儲空間壓力之後,視界紋理圖尺寸變大,使得表現效果較之前自然細膩了一些。不過總色調其實還是大的像素塊。

好吧,新問題來了,那就是表現還是大像素塊。

最終方案,建立在底層優化之上:

正如標題。在底層進行了上述的一系列優化與重構之後,我們其實又有了揮霍的資本。正如人類讓自己變得更好,其實是為了更好去“消費”自己。作為遊戲從業者,川的本職工作就是讓展現在玩家面前的是無與倫比的體驗和感受,如果我們不是像素風遊戲,那麽對於迷霧的大像素塊表現形式,川就是無法容忍的。

那麽最終方案是什麽呢?也許是之前對解決方案的設計耗費太多腦細胞,川想到最直接的方式就是擴容:增加數據量。

其實所謂大像素塊,就是由於數據表達的精度不夠,導致一個數據點需要通過多個物理像素點來表達。所以只要對整體數據進行擴容,就可以接近一個物理像素點表示一個迷霧數據點,甚至再擴容,可以達到一個物理像素點表示多個迷霧數據點(不過這麽做就目前而言似乎並沒有什麽必要)。所以川就是通過對項目進行分析,得出了地圖數據長寬各擴容3倍、總面積擴張到基礎的9倍,可以實現非常棒的視覺體驗。

源代碼:

有些東西還是源代碼看更清晰一些,針對我們所使用的方案,我做了精簡上傳,可以移步:https://github.com/jccg891113/2DMapFog。

聊一聊2D地圖的迷霧效果