用Unity蓋房子(二)——《勇者斗惡龍:建造者2》地形生成和人物控制

作者:沈琰
接著上篇繼續(xù):?用Unity蓋房子(一)——《勇者斗惡龍:建造者2》游戲功能的猜想
上篇用一個相對好理解方式簡單的把基本功能實現(xiàn)了,但想要達到最終目的,僅在此基礎(chǔ)上擴展還不夠,這篇會把地圖的邏輯修改部分,用更為高效的方式來實現(xiàn)。那么之前說好的兩篇完結(jié)這個系列估計篇幅太長,暫定擴展到三篇。
當然,這不是說第一篇的內(nèi)容就是無用功,大家之前對于實現(xiàn)方式的思考也不是白費的,其實這表現(xiàn)了真實開發(fā)過程中的一個常態(tài),完善的代碼邏輯都是要通過反復(fù)思考和工程迭代得來,極少有人能一步到位。
另外,在上期結(jié)尾時我突然想起來咱們專欄之前就過此類功能實現(xiàn)方式的教程,傳送門:
300行代碼實現(xiàn)Minecraft(我的世界)大地圖生成
為了不讓這篇內(nèi)容相對重復(fù),我會在詳細說明思路的基礎(chǔ)上,對前兩篇文章沒有說到的位置做補充說明,大家可以把這篇文章當做此類游戲的思路總結(jié)來看。
地圖邏輯的重構(gòu)
不知道大家有沒有想過,為什么說在上一篇文章的基礎(chǔ)上難以為繼?
之前使用的方式是在每一個方塊的中心點生成一個空物體,根據(jù)它的坐標為基礎(chǔ)生成mesh數(shù)據(jù)。這種方式的好處在于很直觀又容易保存每個方塊的相鄰關(guān)系,且在處理不同朝向的方塊時可以方便的把世界坐標系下的方塊頂點轉(zhuǎn)換為本地坐標系的向量。
雖然說了這么多好處,但還有一個致命問題讓我們不得不放棄這種方法,那就是效率問題。
現(xiàn)在地圖規(guī)模還太小,不太感覺的出來。一旦規(guī)模稍微大一點,比如生成一個長寬高皆為100個小方塊長度的區(qū)域,那么總的小方塊個數(shù)就是100X100X100=100萬個!這么小一點區(qū)域在實際游戲中的大小微不足道,但生成這么一小塊區(qū)域就已經(jīng)是在場景中實例化100萬個物體的巨大效率問題,就別說生成完整的地形了。
不用舍不得老代碼,直接新起一個工程重新開始。雖說之前的代碼全部廢棄了,但仍有一些思路是可以復(fù)用的。
其實之前的思路方向大體沒問題,現(xiàn)在只是要稍做一些修改。我們不再把單獨的一個方塊看做一個實例,而是把多個方塊的集合體看做一個整體塊(chunk),然后用更小的數(shù)據(jù)體去存每個方塊的具體信息。簡而言之就是用一個能裝很多個小方塊的“大方塊”來替代原來小方塊的位置。
為方便計算,大方塊也應(yīng)該是正方形的.所以只用存每個邊長小方塊的個數(shù),同時小方塊的自己的邊長不再限制為1,改設(shè)為一個常量。之前的方塊坐標計算也做相應(yīng)的修改。這里設(shè)置的每個方塊的邊長為2,每個chunk的邊長為16個方塊的長度。

現(xiàn)在重新來寫mesh生成的邏輯。這次不同于之前的工程,我們把mesh生成單獨拆開一個腳本.讓其只用輸入坐標和三角形就能生成任意形狀,方便之后的擴展。大部分邏輯與之前相同,為縮減篇幅,這里就只貼出大致的結(jié)構(gòu),不貼出完整代碼了。

在chunk中也給它一個自定義的坐標結(jié)構(gòu)體用來定位,并修改為屬性,同時在傳入時同時修改chunk的實際位置和名字。


接下來就是根據(jù)信息來生成mesh,這里要修改的地方就比較多了。
從上面的代碼可以看到,用的是一個byte類型的數(shù)組來存儲cube的數(shù)據(jù),也就是每一個cube的數(shù)據(jù)為一個字節(jié)??赡苡型瑢W(xué)會好奇,一個字節(jié)的容量似乎太少,存不了什么東西。但我們可以用位運算的方式可以把需要的基本信息進行壓縮,達到節(jié)省存儲空間的目的。
簡單來說就是,把一個字節(jié)內(nèi)的八位分別用一位存儲是否在場景中存在的信息,一位存是否是邊緣透明的信息(這個會在之后用到),兩位用來存朝向信息,四為用來存種類信息。這樣我們就能保存四種朝向和16種方塊類型,這對于我們目前的小工程來說,也足夠用了。

然后用一個結(jié)構(gòu)體和幾種枚舉寫出相互轉(zhuǎn)換的方法,方便讀取和存儲數(shù)據(jù)。

當然這里的方法不是唯一的,自己做簡單的數(shù)據(jù)映射也是一種很有效的信息壓縮方法,大家可以使用自己習慣的方式。
接著就是根據(jù)chunk內(nèi)存儲的數(shù)據(jù)生成mesh,這里的邏輯與第一篇里的大致相同,只有兩個地方需要修改。
首先,我們在CubeMetrics里存儲的方塊的頂點是相對于方塊自身坐標系的,而現(xiàn)在方塊的朝向是有四種可能變化的。在上一篇文章中,我們在修改方塊朝向的同時也修改了transfrom自身的朝向,然后使用transform.InverseTransformVector()方法把頂點轉(zhuǎn)換為自身坐標系,達到修改生成的mesh朝向問題。
但現(xiàn)在不存在這個實體了,這一步我們沒法偷懶使用現(xiàn)成組件的方法了,因此只能手動去寫一個針對我們這四個朝向的矩陣變換方法,并在生成mesh時對每一個頂點進行轉(zhuǎn)換。


cube數(shù)據(jù)化帶來的另外一個問題是,沒法直接保存每個cube的相鄰引用了。所幸每個cube的相對位置可以通過其在數(shù)據(jù)數(shù)組中的下標算出來。

然后提前把chunk的相鄰引用保存下來,在越界的時候去檢測相鄰的chunk內(nèi)是否存在方塊。

于是就可以通過方塊的面對方向算出這個面是否需要隱藏。

接著寫一點測試代碼,根據(jù)噪聲圖去生成地形數(shù)據(jù)。如果沒有現(xiàn)成的噪聲紋理,unity里的mathf庫里是有封裝好的API的,可以直接使用。


現(xiàn)在還沒設(shè)置UV,但不妨礙通過mesh的形狀驗證邏輯是否正確。

現(xiàn)在我們把UV貼上。這次我終于認識到了自己不是搞美術(shù)材料的事實,老老實實的去挑了一份還看得過去的紋理圖。

這里可以根據(jù)距離地形的深度,在每個地質(zhì)層以隨機概率生成一些礦石之類的東西,就好像真的地形一樣。


地形動態(tài)生成
現(xiàn)在根據(jù)指定的坐標生成地形是沒問題了,但在真正的游戲中,人是可以移動的。我們不能在一開始就把地圖生成很大的范圍,那樣的話加載時間會拉到很長,內(nèi)存占用也是極大。所以只能使用折中的辦法,讓地形隨著人物位置生成,大部分開放世界的邏輯都是如此。
首先思考一下實現(xiàn)方式。讓人物每移動一點距離就把周圍地圖刷新一次顯然不太合適,這樣相當于只要人物在移動時一直都在不停刷新地圖,性能肯定不允許。
理想中的方法是人物在一個區(qū)域內(nèi)移動時地圖不會刷新,但一旦超過這個區(qū)域的位置時刷新區(qū)域的坐標會根據(jù)人物當前位置更新,換句話說就是根據(jù)移動距離有一個閾值來判斷刷新。我們可以直接實時獲取人物坐標,以此為基準計算,但吃不準到底刷新區(qū)域的大小和刷新頻率設(shè)置成多少比較好。因此最好能專門寫一個方法,能方便的調(diào)整測試,我們新建一個類去試試。
用Unity內(nèi)置的Bounds(包圍盒)類來表示刷新的區(qū)域,讓其中一個包圍盒的中心點實時的跟隨人物位置的中心點移動。設(shè)置一個比例參數(shù)去調(diào)整跟隨人物移動的包圍盒與刷新區(qū)域包圍盒之間的大小比例。
邏輯其實很簡單,當較小包圍盒(人物身上的)的最大點和最小點不在較大包圍盒(刷新區(qū)域)里時,調(diào)用刷新方法。


寫這個刷新方法之前,先處理一下根據(jù)移動坐標刷新的大包圍盒中心的方法。由于我們的chunk在世界中的坐標是根據(jù)邊長的固定比例計算的,所以最好把處理后的坐標點與此同步。

接下來就是根據(jù)包圍盒的位置和大小去找到需要刷新哪些chunk。

在chunk的根節(jié)點上新建一個Grid腳本,存儲和管理所有的chunk和其數(shù)據(jù)。按我們的邏輯,在處于刷新區(qū)域內(nèi)時,刷新并顯示當前所有處于其內(nèi)的chunk,而在離開時把可能改動的數(shù)據(jù)存儲在以坐標為key的字典中。在刷新時優(yōu)先讀取以存在的數(shù)據(jù),沒有時才根據(jù)自身坐標生成數(shù)據(jù)。否則我們辛辛苦苦蓋的房子出去走了一圈回來就不見了可就不太對勁了。順便使用對象池來管理chunk,進一步減少GC,這是很常見的優(yōu)化方法,就不把這部分貼出來了。


最后,把需要刷新的chunk放在一個容器中,在協(xié)程中以每幀一個的速度刷新到場景中。這里使用的容器是棧(stack),主要是考慮在移動速度可能過快的情況下,優(yōu)先刷新后改變的chunk,也就是面朝移動方向的先刷新。

最后驗證一下效果:

可以看到速度過快時刷新速度會有些跟不上,但是加快刷新速度又會明顯降低幀數(shù)。這里有很多辦法去優(yōu)化,比如在靜止不動時刷新更大的區(qū)域給予更多緩沖區(qū)域之類的。但對我們的 demo來說,只要限制速度,也足夠用了,這部分的優(yōu)化留給大家自己發(fā)揮。當務(wù)之急是檢測一下對地圖修改后的數(shù)據(jù)能不能正確保存下來。
在地上挖一個大坑,看看出去一圈有沒有變化。

可以看到這部分邏輯也沒問題,那么現(xiàn)在對我們來說,只要內(nèi)存允許,我們的地圖就是“無限“的??梢葬槍@部分做的優(yōu)化還有很多,比如增加進入游戲的加載時間讓開始的地圖變得大一些、優(yōu)化刷新邏輯使其更流暢、地圖數(shù)據(jù)轉(zhuǎn)存到硬盤實現(xiàn)存檔功能等等,不過這些都是后話了。
簡單的人物控制
經(jīng)過前面的邏輯實現(xiàn),這部分已經(jīng)沒什么好寫的了。功能與方法的接口在前面都已經(jīng)搭好了,照著去調(diào)用就是了。
關(guān)于人物的移動和視角還有個很有意思的地方。大家都知道,大部分的類似方塊建造的開放世界游戲大多使用的是第一人稱視角,但DQ2卻是第三人稱的!雖然也能切回第一人稱視角就是了。我能大概理解可能是基于RPG要素的考慮設(shè)計了這么一個操作模式,但是在游玩的時候覺得有些需要精確建造的位置確實有點不方便,很難把視角對準。但當我嘗試去復(fù)現(xiàn)的時候,才發(fā)現(xiàn)DQ2的第三人稱視角已經(jīng)是做過許多設(shè)計和功能上優(yōu)化了(以玩家角度很難感覺到開發(fā)者的苦?。耆珡?fù)現(xiàn)實在是工作量太大。所以抱歉的容我在這偷個懶,用第三人稱移動和第一人稱建造的混合模式去糊弄過去(這其實有點像DQ2后期的建造師手套功能,也算是某種程度上的還原吧)。
首先是人物,二頭身的3D模型還加動畫,這個要求分明就是在刁難胖虎,不過還好找了很久之后,終于找到一個水手服雙馬尾**,還不要錢。(我沒有在開車,你們也沒證據(jù))


現(xiàn)在人物有了,下一步就是人物動畫,移動控制和相機控制,這部分任何游戲都可能會遇到,與今天的主題沒多大直接關(guān)系,不在此贅述了,對這部分有興趣的同學(xué)自己下載工程吧。
總之,在掛上控制腳本后,把之前的刷新地圖腳本也掛載在人物身上,我們的小**就可以在無限寬廣的世界中任意遨游了。

這里還需要說一下一個我遇到的小問題,mesh刷新時這里是直接把原來的緩存數(shù)據(jù)的mesh清空,讀值之后再給meshCollider賦值的。這樣會帶來的問題是當賦了一個沒有頂點的空mesh之后,這個chunk的meshCollider就會失效,之前測試時我的小**經(jīng)常跑著跑著就掉下去了。
后經(jīng)查閱,推測是在給meshCollider賦值時,如果引用不變,無法觸發(fā)set屬性達到刷新碰撞的目的,因此這個地方稍作修改就行了。

還沒完,現(xiàn)在還需要讓小**能蓋房子,不然都對不起這個標題。這個時候另外8個剛才沒用到的方塊紋理就派上用場了,先做個簡單的UI,用toggle組件把圖標與放置方塊時的類型一一對應(yīng)起來。



根據(jù)鼠標位置發(fā)射一條射線,把打倒chunk的坐標點換算到chunk的自身坐標里,再去修改方塊的數(shù)據(jù)。



最后,在人物的update里實時去刷新這個坐標的值,僅在點擊時調(diào)用setData方法。為了方便觀察邏輯是否正確,為當前射線擊中坐標添加一個預(yù)覽方塊,并添加一點粒子效果表示chunk上數(shù)據(jù)的修改。

另外提一句預(yù)覽框的效果怎么做。png格式的圖片是可以保存alpha通道信息的,但是直接把這種鏤空紋理的圖拖入材質(zhì)球是沒效果的,還要把標準著色器里的渲染模式改成Cutout。

結(jié)束
現(xiàn)在基本邏輯都大概還原了,總算是站在了最先想要實現(xiàn)功能的起點上,老實說在一開始的時候真沒想到要繞這么大一個圈。這期的內(nèi)容可能會有些多,但是以文章為載體我沒辦法說的很詳細,所以感興趣的同學(xué)還是下載工程研究吧,有不明白的或者我寫的有問題的地方,都可以在評論區(qū)留言,感謝觀看至此。
本期工程地址:https://github.com/tank1018702/CubeBuilder
有意向參與線下游戲開發(fā)學(xué)習的童鞋,歡迎訪問http://levelpp.com/。