一 效果圖
先上效果圖吧,這是為了吸引到你們的ヽ(。◕‿◕。)ノ゚
戰爭迷霧效果演示圖
戰爭迷霧調試界面演示圖
由於是gif錄制,為了壓縮圖片,幀率有點低,實際運行時,參數調整好是不會像這樣一卡一頓的。
二 戰爭迷霧概述
戰爭迷霧一般用於Startcraft等RTS類型游戲,還有就是War3等Moba類型游戲,主要包括三個概念:未探索區域、已探索區域、當前視野。
1)未探索區域:一般展示為黑色區域,像星際爭霸這樣的游戲,開局時未探索區域一般是暗黑的,只有地圖上的原始晶體礦產能夠被看到,敵人建築、角色等都不暴露。
2)已探索區域:一般顯示為灰色區域,已探索表示某塊區域曾經被你的視野覆蓋過,星際爭霸中已探索的區域會保留你當時視野離開時該區域的建築狀態,所以可以看到敵人的建築。
3)當前視野:一般全亮,視野范圍內除了隱身單位等特殊設定,所有的建築、角色、特效等都是可見的,視野一般鎖定在可移動角色或者特定魔法上面,會隨着角色的移動而移動,隨着魔法的消失而消失。
三 實現原理
戰爭迷霧的實現方式大體上可以分為兩個步驟:貼圖生成、屏幕渲染。
3.1 貼圖生成
貼圖的生成有兩種方式:
1)拼接法:
使用類似地圖拼接的原理去實現,貼圖如下:
戰爭迷霧拼接貼圖
這種方式個人認為很不靠譜,局限性很大,而且迷霧總是會運動的,在平滑處理這點上會比較粗糙,不太自然。這里不再贅述它的實現原理。
2)繪制法:繪制法和使用的地圖模型有很大關系,一般使用的有兩種模型:一個是正方形地圖,另外一個是六邊形地圖。六邊形地圖示例如下:
戰爭迷霧六邊形地圖貼圖
原理簡單直白,使用正方形/者六邊形划分地圖空間,以正方形/六邊形為單位標記被探索過和當前視野區域。這里探索過的區域是棱角分明的,可以使用高斯模糊進行模糊處理。一般來說,正方形/六邊形邊長要選擇合適,太長會導致模糊處理效果不理想,太短會導致地圖單元格太多,全圖刷新消耗增大。另外說一句,戰爭迷霧的地圖和戰斗系統的邏輯地圖其實是可以分離的,所以兩者並沒有必然聯系,你可以單獨為你的戰爭迷霧系統選擇地圖模型。我也建議你不管是不是同一套地圖,實現時都實現解耦。
3.2 屏幕渲染
得到如上貼圖以后,就可以渲染到屏幕了,渲染方式一般來說有3種:
1)屏幕后處理:在原本屏幕顯示圖像上疊加混合戰爭迷霧貼圖。
2)攝像機投影:使用投影儀進行投影,將戰爭迷霧投影到世界空間。
3)模型貼圖:使用一張覆蓋整個世界空間的平面模型來繪制戰爭迷霧貼圖。
不管你選擇使用哪一種方式,在這一步當中都需要在Shader里進行像素級別的平滑過渡。從上一個時刻的貼圖狀態過渡到當前時刻的貼圖狀態。
四 代碼實現
原理大致上應該是清楚了,因為這個系統的設計原理實際上也不算是復雜,下面就一些重要步驟給出代碼實現。這里實踐的時候采用的是正方形地圖,模型貼圖方式。正方形地圖模型不管是模糊處理還是Shader繪制都要比六邊形地圖簡單。正方形貼圖Buffer使用Color32的二維數組表示,根據位置信息,每個正方形網格會對應一個Color32數據,包含顏色值和透明度,能夠很好的進行邊緣平滑效果。
1 // Color buffers -- prepared on the worker thread. 2 protected Color32[] mBuffer0; 3 protected Color32[] mBuffer1; 4 protected Color32[] mBuffer2;
這里使用了3個Buffer,是因為圖像處理是很耗時的,所以為它單獨開辟了線程去處理,為了線程同步問題,才增設了Buffer,關於線程這點稍后再說。
4.1 刷新貼圖Buffer
貼圖Buffer需要根據游戲邏輯中各個帶有視野的單位去實時刷新,在正方形地圖模型中,是根據單位當前位置和視野半徑做圓,將圓內圈住的小正方形標記為探索。
1 void RevealUsingRadius (IFOWRevealer r, float worldToTex) 2 { 3 // Position relative to the fog of war 4 Vector3 pos = (r.GetPosition() - mOrigin) * worldToTex; 5 float radius = r.GetRadius() * worldToTex - radiusOffset; 6 7 // Coordinates we'll be dealing with 8 int xmin = Mathf.RoundToInt(pos.x - radius); 9 int ymin = Mathf.RoundToInt(pos.z - radius); 10 int xmax = Mathf.RoundToInt(pos.x + radius); 11 int ymax = Mathf.RoundToInt(pos.z + radius); 12 13 int cx = Mathf.RoundToInt(pos.x); 14 int cy = Mathf.RoundToInt(pos.z); 15 16 cx = Mathf.Clamp(cx, 0, textureSize - 1); 17 cy = Mathf.Clamp(cy, 0, textureSize - 1); 18 19 int radiusSqr = Mathf.RoundToInt(radius * radius); 20 21 for (int y = ymin; y < ymax; ++y) 22 { 23 if (y > -1 && y < textureSize) 24 { 25 int yw = y * textureSize; 26 27 for (int x = xmin; x < xmax; ++x) 28 { 29 if (x > -1 && x < textureSize) 30 { 31 int xd = x - cx; 32 int yd = y - cy; 33 int dist = xd * xd + yd * yd; 34 35 // Reveal this pixel 36 if (dist < radiusSqr) mBuffer1[x + yw].r = 255; 37 } 38 } 39 } 40 } 41 }
第一個參數包含了視野單位的信息,包括位置和視野半徑;第二個參數為世界坐標到貼圖坐標的坐標變換,R通道用於記錄視野信息。
4.2 貼圖Buffer模糊
每次貼圖刷新以后,進行一次貼圖模糊處理。
1 void BlurVisibility () 2 { 3 Color32 c; 4 5 for (int y = 0; y < textureSize; ++y) 6 { 7 int yw = y * textureSize; 8 int yw0 = (y - 1); 9 if (yw0 < 0) yw0 = 0; 10 int yw1 = (y + 1); 11 if (yw1 == textureSize) yw1 = y; 12 13 yw0 *= textureSize; 14 yw1 *= textureSize; 15 16 for (int x = 0; x < textureSize; ++x) 17 { 18 int x0 = (x - 1); 19 if (x0 < 0) x0 = 0; 20 int x1 = (x + 1); 21 if (x1 == textureSize) x1 = x; 22 23 int index = x + yw; 24 int val = mBuffer1[index].r; 25 26 val += mBuffer1[x0 + yw].r; 27 val += mBuffer1[x1 + yw].r; 28 val += mBuffer1[x + yw0].r; 29 val += mBuffer1[x + yw1].r; 30 31 val += mBuffer1[x0 + yw0].r; 32 val += mBuffer1[x1 + yw0].r; 33 val += mBuffer1[x0 + yw1].r; 34 val += mBuffer1[x1 + yw1].r; 35 36 c = mBuffer2[index]; 37 c.r = (byte)(val / 9); 38 mBuffer2[index] = c; 39 } 40 } 41 42 // Swap the buffer so that the blurred one is used 43 Color32[] temp = mBuffer1; 44 mBuffer1 = mBuffer2; 45 mBuffer2 = temp; 46 }
用周圍的8個小正方形進行了加權模糊,這里並沒有像高斯模糊那樣去分不同的權重。
4.3 Buffer運用到貼圖
Buffer一旦處理完畢,就可以生成/刷新貼圖供屏幕顯示用,不管你使用上述方式中的哪一種,在Shader執行貼圖采樣時,這張貼圖是必須的。
1 void UpdateTexture () 2 { 3 if (!enableRender) 4 { 5 return; 6 } 7 8 if (mTexture == null) 9 { 10 // Native ARGB format is the fastest as it involves no data conversion 11 mTexture = new Texture2D(textureSize, textureSize, TextureFormat.ARGB32, false); 12 13 mTexture.wrapMode = TextureWrapMode.Clamp; 14 15 mTexture.SetPixels32(mBuffer0); 16 mTexture.Apply(); 17 mState = State.Blending; 18 } 19 else if (mState == State.UpdateTexture) 20 { 21 mTexture.SetPixels32(mBuffer0); 22 mTexture.Apply(); 23 mBlendFactor = 0f; 24 mState = State.Blending; 25 } 26 }
4.4 屏幕渲染
主要是做兩件事情:CS測在OnWillRenderObject給Shader傳遞參數;另外就是Shader中根據最新的戰爭迷霧貼圖和戰爭迷霧顏色設定執行平滑過渡。
1 void OnWillRenderObject() 2 { 3 if (mMat != null && FOWSystem.instance.texture != null) 4 { 5 mMat.SetTexture("_MainTex", FOWSystem.instance.texture); 6 mMat.SetFloat("_BlendFactor", FOWSystem.instance.blendFactor); 7 if (FOWSystem.instance.enableFog) 8 { 9 mMat.SetColor("_Unexplored", unexploredColor); 10 } 11 else 12 { 13 mMat.SetColor("_Unexplored", exploredColor); 14 } 15 mMat.SetColor("_Explored", exploredColor); 16 } 17 }
其中blendFactor是過渡因子,會在Update中根據時間刷新,用於控制Shader的平滑過渡過程。
1 fixed4 frag(v2f i) : SV_Target 2 { 3 half4 data = tex2D(_MainTex, i.uv); 4 half2 fog = lerp(data.rg, data.ba, _BlendFactor); 5 half4 color = lerp(_Unexplored, _Explored, fog.g); 6 color.a = (1 - fog.r) * color.a; 7 return color; 8 } 9 ENDCG
data是貼圖,rg和ba通道是連續的兩個戰爭迷霧狀態的數據,其中r通道表示當前是否可見(是否在視野內),g通道表示是否被探索過(大於0則探索過)。
4.5 多線程
本例當作,貼圖Buffer的刷新和模糊處理是在子線程處理的;而Buffer運用到貼圖在主線程中;屏幕渲染在GPU當作。所以Unity主線程只是在不停地刷新貼圖,而貼圖Buffer和模糊處理這兩個很耗性能的操作全部由子線程代勞,這就是標題所說的“高性能”原因所在,即使子線程每次的處理周期達到30毫秒,它依舊不會影響到游戲幀率。
多線程編程必然要考慮的一點是線程同步,此處主要的問題有兩個:
1)工作子線程輸入:刷新貼圖Buffer需要Unity主線程(或者游戲邏輯主線程)中游戲中的視野體數據(位置、視野半徑)
2)工作子線程輸出:由最新的游戲邏輯數據刷新貼圖Buffer,以及進行貼圖Buffer混合以后,要在Unity主線程將數據運用到貼圖
工作子線程的輸入同步問題稍后再說,這里說下第二步是怎樣去保證同步的,其大致步驟是:
1)設置3個狀態用於線程同步:
1 public enum State 2 { 3 Blending, 4 NeedUpdate, 5 UpdateTexture, 6 }
2)NeedUpdate表示子線程需要處理貼圖Buffer,這個狀態的設置是由設定的刷新頻率和實際處理時的刷新速度決定的:
1 void ThreadUpdate() 2 { 3 System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch(); 4 5 while (mThreadWork) 6 { 7 if (mState == State.NeedUpdate) 8 { 9 sw.Reset(); 10 sw.Start(); 11 UpdateBuffer(); 12 sw.Stop(); 13 mElapsed = 0.001f * (float)sw.ElapsedMilliseconds; 14 mState = State.UpdateTexture; 15 } 16 Thread.Sleep(1); 17 } 18 #if UNITY_EDITOR 19 Debug.Log("FOW thread exit!"); 20 #endif 21 }
3)子線程會將Unity主線程(或者游戲邏輯線程)提供的最新視野狀態數據刷新到貼圖Buffer1的R通道,然后使用Buffer2做臨時緩存對Buffer1執行模糊,模糊以后交換雙緩存,最后將Buffer1的rg通道拷貝到Buffer0,所以Buffer0的ba和rg通道分別存放了上一次刷新和當前本次刷新的戰爭迷霧狀態數據,Buffer0運用到貼圖以后由Shader在這兩個狀態間進行平滑過渡。
1 void RevealMap () 2 { 3 for (int index = 0; index < mTextureSizeSqr; ++index) 4 { 5 if (mBuffer1[index].g < mBuffer1[index].r) 6 { 7 mBuffer1[index].g = mBuffer1[index].r; 8 } 9 } 10 } 11 12 void MergeBuffer() 13 { 14 for (int index = 0; index < mTextureSizeSqr; ++index) 15 { 16 mBuffer0[index].b = mBuffer1[index].r; 17 mBuffer0[index].a = mBuffer1[index].g; 18 } 19 }
4)子線程工作處理完以后設置UpdateTexture狀態,通知Unity主線程:“嘿,飯已經做好了,你來吃吧!”,Unity主線程隨后將Buffer0緩存運用到貼圖。
1 void Update () 2 { 3 if (!enableSystem) 4 { 5 return; 6 } 7 8 if (textureBlendTime > 0f) 9 { 10 mBlendFactor = Mathf.Clamp01(mBlendFactor + Time.deltaTime / textureBlendTime); 11 } 12 else mBlendFactor = 1f; 13 14 if (mState == State.Blending) 15 { 16 float time = Time.time; 17 18 if (mNextUpdate < time) 19 { 20 mNextUpdate = time + updateFrequency; 21 mState = State.NeedUpdate; 22 } 23 } 24 else if (mState != State.NeedUpdate) 25 { 26 UpdateTexture(); 27 } 28 }
5)UpdateTexture執行完畢以后,進入Blending狀態,此時Unity主線程要等待下一次更新時間,時間到則設置NeedUpdate狀態,通知子線程:“嘿,家伙,你該做飯了!”。
4.6 模塊分離
上面講到貼圖Buffer刷新子線程和Unity渲染主線程的同步與臨界資源的互斥,現在來說說Unity主線程(游戲邏輯主線程)與貼圖Buffer刷新子線程的同步。
1)使用互斥鎖同步視野體生命周期
1 // Revealers that the thread is currently working with 2 static BetterList<IFOWRevealer> mRevealers = new BetterList<IFOWRevealer>(); 3 4 // Revealers that have been added since last update 5 static BetterList<IFOWRevealer> mAdded = new BetterList<IFOWRevealer>(); 6 7 // Revealers that have been removed since last update 8 static BetterList<IFOWRevealer> mRemoved = new BetterList<IFOWRevealer>(); 9 10 static public void AddRevealer (IFOWRevealer rev) 11 { 12 if (rev != null) 13 { 14 lock (mAdded) mAdded.Add(rev); 15 } 16 } 17 18 static public void RemoveRevealer (IFOWRevealer rev) 19 { 20 if (rev != null) 21 { 22 lock (mRemoved) mRemoved.Add(rev); 23 } 24 }
這個應該沒啥好說的,子線程在處理這兩個列表時同樣需要加鎖。
2)視野體使用IFOWRevelrs接口,方便模塊隔離和擴展。同步問題這里采用了一種簡單粗暴的方式,由於戰爭迷霧屬於表現層面的東西,即使用於幀同步也不會有問題。
1 public interface IFOWRevealer 2 { 3 // 給FOWSystem使用的接口 4 bool IsValid(); 5 Vector3 GetPosition(); 6 float GetRadius(); 7 8 // 給FOWLogic使用的接口,維護數據以及其有效性 9 void Update(int deltaMS); 10 void Release(); 11 }
繼承IFOWRevealer接口用來實現各種不同的視野體,本示例中給出了角色視野體與臨時視野體的實現,其它視野體自行根據需要擴展。
五 其它說明
其它還有FOWlogic模塊用來隔離FOW系統和游戲邏輯,FOWRender用於fow渲染等,不再一一說明,自行閱讀代碼。
有關六邊形地圖的戰爭迷霧實現稍作變通應該做起來問題也不是太大,相關信息可以參考:Hex Map 21 Exploration和Hex Map 22 Advanced Vision。
這一系列文章都有譯文,英文不好的同學參考:Unity 六邊形地圖系列(二十一):探索和Unity 六邊形地圖系列(二十二) :高級視野效果。
然后,本演示工程的核心算法是由TasharenFogOfWar移植而來的,該插件由NGUI作者發布,不過已經被我大幅修改。
六 工程下載
最后附上本演示工程的GitHub地址:https://github.com/smilehao/fog-of-war。