一 效果图
先上效果图吧,这是为了吸引到你们的ヽ(。◕‿◕。)ノ゚
战争迷雾效果演示图
战争迷雾调试界面演示图
由于是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 BetterListmRevealers = new BetterList (); 3 4 // Revealers that have been added since last update 5 static BetterList mAdded = new BetterList (); 6 7 // Revealers that have been removed since last update 8 static BetterList mRemoved = new BetterList (); 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。