Unity实现战争迷雾(RawImage)

通过在场景上再覆盖一个RawImage,计算物体的相对坐标改变对应坐标的A值。
效果不是很好,可能只能勉强应付下2D场景,就不赘述,简单记录一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
using UnityEngine;
using UnityEngine.UI;
/****************************************************
文件:FogOfWar.cs
作者:https://afoolzwt.github.io/
邮箱: 848832649@qq.com
日期:#CreateTime#
功能:Nothing
*****************************************************/
public class FogOfWar : MonoBehaviour
{
public RawImage fogRawImage;

/// <summary>
/// 迷雾的像素密度,由多少个点组成.
/// </summary>
public Vector2Int fogDensity = new Vector2Int(100, 100);

private Texture2D fogTexture;

public Transform cubeTrans;
public MeshCollider planeMesh;
private Vector2 planeOriginPoint;
private Vector2 worldSize;
private void Start()
{
fogTexture = new Texture2D(fogDensity.x, fogDensity.y);
fogRawImage.texture = fogTexture;

worldSize = new Vector2(planeMesh.bounds.size.x, planeMesh.bounds.size.z);
//将plane的坐标减去它尺寸的一半,即可得到它的左下角的坐标
planeOriginPoint = new Vector2(planeMesh.transform.position.x - worldSize.x * 0.5f, planeMesh.transform.position.z - worldSize.y * 0.5f);

InitShape();
InitFog();

EliminateFog();
}

private void InitFog()
{
int pixelCount = fogDensity.x * fogDensity.y;
//将迷雾的默认颜色设置为黑色
Color[] blackColors = new Color[pixelCount];
for (int i = 0; i < pixelCount; i++)
{
blackColors[i].a = 127.5f/255;
}
fogTexture.SetPixels(blackColors);
fogTexture.Apply();
}

/// <summary>
/// 消除形状的长宽
/// </summary>
public Vector2Int beEliminatedShapeSize = new Vector2Int(8, 6);

private Vector2Int[] shapeLocalPosition;

/// <summary>
/// 初始化视野形状
/// </summary>
private void InitShape()
{
int pixelCount = beEliminatedShapeSize.x * beEliminatedShapeSize.y;
shapeLocalPosition = new Vector2Int[pixelCount];

int halfX = Mathf.FloorToInt(beEliminatedShapeSize.x * 0.5f);
int remainX = beEliminatedShapeSize.x - halfX;
int halfY = Mathf.FloorToInt(beEliminatedShapeSize.y * 0.5f);
int remainY = beEliminatedShapeSize.y - halfY;

int index = 0;
for (int y = -halfY; y < remainY; y++)
{
for (int x = -halfX; x < remainX; x++)
{
shapeLocalPosition[index] = new Vector2Int(x, y);
index++;
}
}
}


private void EliminateFog()
{
Vector2 cubePos = new Vector2(cubeTrans.position.x, cubeTrans.position.z);

Debug.Log(cubePos.x + cubePos.y);

//相对假定原点的距离比例,因为是世界坐标,两个点相减有可能是负数,texture中不存在负数的坐标,所以转化为正数.
Vector2 originDistanceRatio = (cubePos - planeOriginPoint) / worldSize;
originDistanceRatio.Set(Mathf.Abs(originDistanceRatio.x), Mathf.Abs(originDistanceRatio.y));
//距离比例乘以密度,即可知道cube相当在texture中的点即可计算出来
Vector2Int fogCenter = new Vector2Int(Mathf.RoundToInt(originDistanceRatio.x * fogDensity.x), Mathf.RoundToInt(originDistanceRatio.y * fogDensity.y));
for (int i = 0; i < shapeLocalPosition.Length; i++)
{
int x = shapeLocalPosition[i].x + fogCenter.x;
int y = shapeLocalPosition[i].y + fogCenter.y;
//因为消除迷雾的形状是比cube的位置还要大的,在最边缘的时候,消除的像素点的坐标会超出texture范围,所以超出部分忽略.
if (x < 0 || x >= fogDensity.x || y < 0 || y >= fogDensity.y)
continue;

fogTexture.SetPixel(x, y, Color.clear);
}

fogTexture.Apply();
}

public float cubeMoveSpeed = 0.1f;
private void Update()
{
if (Input.anyKey)
{
if (Input.GetKey(KeyCode.W))
{
cubeTrans.position += Vector3.forward * cubeMoveSpeed;
}
else if (Input.GetKey(KeyCode.S))
{
cubeTrans.position += Vector3.back * cubeMoveSpeed;
}
else if (Input.GetKey(KeyCode.A))
{
cubeTrans.position += Vector3.left * cubeMoveSpeed;
}
else if (Input.GetKey(KeyCode.D))
{
cubeTrans.position += Vector3.right * cubeMoveSpeed;
}

EliminateFog();
}
}
}

效果如下:
1.png

Unity实现战争迷雾(Shader)

战争迷雾的实现方式大体上可以分为两个步骤:贴图生成、屏幕渲染。

贴图生成

贴图的生成有两种方式:
1)拼接法:
使用类似地图拼接的原理去实现,贴图如下:
2.png
这种方式个人认为很不靠谱,局限性很大,而且迷雾总是会运动的,在平滑处理这点上会比较粗糙,不太自然。这里不再赘述它的实现原理。
2)绘制法:绘制法和使用的地图模型有很大关系,一般使用的有两种模型:一个是正方形地图,另外一个是六边形地图。六边形地图示例如下:
3.jpg
原理简单直白,使用正方形/者六边形划分地图空间,以正方形/六边形为单位标记被探索过和当前视野区域。这里探索过的区域是棱角分明的,可以使用高斯模糊进行模糊处理。一般来说,正方形/六边形边长要选择合适,太长会导致模糊处理效果不理想,太短会导致地图单元格太多,全图刷新消耗增大。另外说一句,战争迷雾的地图和战斗系统的逻辑地图其实是可以分离的,所以两者并没有必然联系,你可以单独为你的战争迷雾系统选择地图模型。我也建议你不管是不是同一套地图,实现时都实现解耦。

屏幕渲染

得到如上贴图以后,就可以渲染到屏幕了,渲染方式一般来说有3种:
  1)屏幕后处理:在原本屏幕显示图像上叠加混合战争迷雾贴图。
  2)摄像机投影:使用投影仪进行投影,将战争迷雾投影到世界空间。
  3)模型贴图:使用一张覆盖整个世界空间的平面模型来绘制战争迷雾贴图。
  不管你选择使用哪一种方式,在这一步当中都需要在Shader里进行像素级别的平滑过渡。从上一个时刻的贴图状态过渡到当前时刻的贴图状态。

代码实现

原理大致上应该是清楚了,因为这个系统的设计原理实际上也不算是复杂,下面就一些重要步骤给出代码实现。这里实践的时候采用的是正方形地图,模型贴图方式。正方形地图模型不管是模糊处理还是Shader绘制都要比六边形地图简单。正方形贴图Buffer使用Color32的二维数组表示,根据位置信息,每个正方形网格会对应一个Color32数据,包含颜色值和透明度,能够很好的进行边缘平滑效果。

1
2
3
4
// Color buffers -- prepared on the worker thread.
protected Color32[] mBuffer0;
protected Color32[] mBuffer1;
protected Color32[] mBuffer2;

这里使用了3个Buffer,是因为图像处理是很耗时的,所以为它单独开辟了线程去处理,为了线程同步问题,才增设了Buffer,关于线程这点稍后再说。

刷新贴图Buffer

贴图Buffer需要根据游戏逻辑中各个带有视野的单位去实时刷新,在正方形地图模型中,是根据单位当前位置和视野半径做圆,将圆内圈住的小正方形标记为探索。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/// <summary>
/// The fastest form of visibility updates -- radius-based, no line of sights checks.
/// </summary>
void RevealUsingRadius (IFOWRevealer r, float worldToTex)
{
// Position relative to the fog of war
Vector3 pos = (r.GetPosition() - mOrigin) * worldToTex;
float radius = r.GetRadius() * worldToTex - radiusOffset;

// Coordinates we'll be dealing with
int xmin = Mathf.RoundToInt(pos.x - radius);
int ymin = Mathf.RoundToInt(pos.z - radius);
int xmax = Mathf.RoundToInt(pos.x + radius);
int ymax = Mathf.RoundToInt(pos.z + radius);

int cx = Mathf.RoundToInt(pos.x);
int cy = Mathf.RoundToInt(pos.z);

cx = Mathf.Clamp(cx, 0, textureSize - 1);
cy = Mathf.Clamp(cy, 0, textureSize - 1);

int radiusSqr = Mathf.RoundToInt(radius * radius);

for (int y = ymin; y < ymax; ++y)
{
if (y > -1 && y < textureSize)
{
int yw = y * textureSize;

for (int x = xmin; x < xmax; ++x)
{
if (x > -1 && x < textureSize)
{
int xd = x - cx;
int yd = y - cy;
int dist = xd * xd + yd * yd;

// Reveal this pixel
if (dist < radiusSqr) mBuffer1[x + yw].r = 255;
}
}
}
}
}

第一个参数包含了视野单位的信息,包括位置和视野半径;第二个参数为世界坐标到贴图坐标的坐标变换,R通道用于记录视野信息。

贴图Buffer模糊

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/// <summary>
/// Blur the visibility data.
/// </summary>
void BlurVisibility ()
{
Color32 c;

for (int y = 0; y < textureSize; ++y)
{
int yw = y * textureSize;
int yw0 = (y - 1);
if (yw0 < 0) yw0 = 0;
int yw1 = (y + 1);
if (yw1 == textureSize) yw1 = y;

yw0 *= textureSize;
yw1 *= textureSize;

for (int x = 0; x < textureSize; ++x)
{
int x0 = (x - 1);
if (x0 < 0) x0 = 0;
int x1 = (x + 1);
if (x1 == textureSize) x1 = x;

int index = x + yw;
int val = mBuffer1[index].r;

val += mBuffer1[x0 + yw].r;
val += mBuffer1[x1 + yw].r;
val += mBuffer1[x + yw0].r;
val += mBuffer1[x + yw1].r;

val += mBuffer1[x0 + yw0].r;
val += mBuffer1[x1 + yw0].r;
val += mBuffer1[x0 + yw1].r;
val += mBuffer1[x1 + yw1].r;

c = mBuffer2[index];
c.r = (byte)(val / 9);
mBuffer2[index] = c;
}
}

// Swap the buffer so that the blurred one is used
Color32[] temp = mBuffer1;
mBuffer1 = mBuffer2;
mBuffer2 = temp;
}

用周围的8个小正方形进行了加权模糊,这里并没有像高斯模糊那样去分不同的权重。

Buffer运用到贴图

Buffer一旦处理完毕,就可以生成/刷新贴图供屏幕显示用,不管你使用上述方式中的哪一种,在Shader执行贴图采样时,这张贴图是必须的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
   /// <summary>
/// Update the specified texture with the new color buffer.
/// </summary>
void UpdateTexture ()
{
if (!enableRender)
{
return;
}

if (mTexture == null)
{
// Native ARGB format is the fastest as it involves no data conversion
mTexture = new Texture2D(textureSize, textureSize, TextureFormat.ARGB32, false);

mTexture.wrapMode = TextureWrapMode.Clamp;

mTexture.SetPixels32(mBuffer0);
mTexture.Apply();
mState = State.Blending;
}
else if (mState == State.UpdateTexture)
{
mTexture.SetPixels32(mBuffer0);
mTexture.Apply();
mBlendFactor = 0f;
mState = State.Blending;
}
}

屏幕渲染

主要是做两件事情:CS侧在OnWillRenderObject给Shader传递参数;另外就是Shader中根据最新的战争迷雾贴图和战争迷雾颜色设定执行平滑过渡。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void OnWillRenderObject()
{
if (mMat != null && FOWSystem.instance.texture != null)
{
mMat.SetTexture("_MainTex", FOWSystem.instance.texture);
mMat.SetFloat("_BlendFactor", FOWSystem.instance.blendFactor);
if (FOWSystem.instance.enableFog)
{
mMat.SetColor("_Unexplored", unexploredColor);
}
else
{
mMat.SetColor("_Unexplored", exploredColor);
}
mMat.SetColor("_Explored", exploredColor);
}
}

其中blendFactor是过渡因子,会在Update中根据时间刷新,用于控制Shader的平滑过渡过程。

1
2
3
4
5
6
7
8
fixed4 frag(v2f i) : SV_Target
{
half4 data = tex2D(_MainTex, i.uv);
half2 fog = lerp(data.rg, data.ba, _BlendFactor);
half4 color = lerp(_Unexplored, _Explored, fog.g);
color.a = (1 - fog.r) * color.a;
return color;
}

 data是贴图,rg和ba通道是连续的两个战争迷雾状态的数据,其中r通道表示当前是否可见(是否在视野内),g通道表示是否被探索过(大于0则探索过)。

多线程

本例当中,贴图Buffer的刷新和模糊处理是在子线程处理的;而Buffer运用到贴图在主线程中;屏幕渲染在GPU当中。所以Unity主线程只是在不停地刷新贴图,而贴图Buffer和模糊处理这两个很耗性能的操作全部由子线程代劳,这就是所说的“高性能”原因所在,即使子线程每次的处理周期达到30毫秒,它依旧不会影响到游戏帧率。
多线程编程必然要考虑的一点是线程同步,此处主要的问题有两个:
    1)工作子线程输入:刷新贴图Buffer需要Unity主线程(或者游戏逻辑主线程)中游戏中的视野体数据(位置、视野半径)
    2)工作子线程输出:由最新的游戏逻辑数据刷新贴图Buffer,以及进行贴图Buffer混合以后,要在Unity主线程将数据运用到贴图
工作子线程的输入同步问题稍后再说,这里说下第二步是怎样去保证同步的,其大致步骤是:
1)设置3个状态用于线程同步:

1
2
3
4
5
6
public enum State
{
Blending,
NeedUpdate,
UpdateTexture,
}

2)NeedUpdate表示子线程需要处理贴图Buffer,这个状态的设置是由设定的刷新频率和实际处理时的刷新速度决定的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void ThreadUpdate()
{
System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();

while (mThreadWork)
{
if (mState == State.NeedUpdate)
{
sw.Reset();
sw.Start();
UpdateBuffer();
sw.Stop();
mElapsed = 0.001f * (float)sw.ElapsedMilliseconds;
mState = State.UpdateTexture;
}
Thread.Sleep(1);
}
#if UNITY_EDITOR
Debug.Log("FOW thread exit!");
#endif
}

3)子线程会将Unity主线程(或者游戏逻辑线程)提供的最新视野状态数据刷新到贴图Buffer1的R通道,然后使用Buffer2做临时缓存对Buffer1执行模糊,模糊以后交换双缓存,最后将Buffer1的rg通道拷贝到Buffer0,所以Buffer0的ba和rg通道分别存放了上一次刷新和当前本次刷新的战争迷雾状态数据,Buffer0运用到贴图以后由Shader在这两个状态间进行平滑过渡。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void RevealMap ()
{
for (int index = 0; index < mTextureSizeSqr; ++index)
{
if (mBuffer1[index].g < mBuffer1[index].r)
{
mBuffer1[index].g = mBuffer1[index].r;
}
}
}

void MergeBuffer()
{
for (int index = 0; index < mTextureSizeSqr; ++index)
{
mBuffer0[index].b = mBuffer1[index].r;
mBuffer0[index].a = mBuffer1[index].g;
}
}

4)子线程工作处理完以后设置UpdateTexture状态,通知Unity主线程:“嘿,饭已经做好了,你来吃吧!”,Unity主线程随后将Buffer0缓存运用到贴图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void Update ()
{
if (!enableSystem)
{
return;
}

if (textureBlendTime > 0f)
{
mBlendFactor = Mathf.Clamp01(mBlendFactor + Time.deltaTime / textureBlendTime);
}
else mBlendFactor = 1f;

if (mState == State.Blending)
{
float time = Time.time;

if (mNextUpdate < time)
{
mNextUpdate = time + updateFrequency;
mState = State.NeedUpdate;
}
}
else if (mState != State.NeedUpdate)
{
UpdateTexture();
}
}

5)UpdateTexture执行完毕以后,进入Blending状态,此时Unity主线程要等待下一次更新时间,时间到则设置NeedUpdate状态,通知子线程:“嘿,家伙,你该做饭了!”。

模块分离

上面讲到贴图Buffer刷新子线程和Unity渲染主线程的同步与临界资源的互斥,现在来说说Unity主线程(游戏逻辑主线程)与贴图Buffer刷新子线程的同步。
1)使用互斥锁同步视野体生命周期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Revealers that the thread is currently working with
static BetterList<IFOWRevealer> mRevealers = new BetterList<IFOWRevealer>();

// Revealers that have been added since last update
static BetterList<IFOWRevealer> mAdded = new BetterList<IFOWRevealer>();

// Revealers that have been removed since last update
static BetterList<IFOWRevealer> mRemoved = new BetterList<IFOWRevealer>();

static public void AddRevealer (IFOWRevealer rev)
{
if (rev != null)
{
lock (mAdded) mAdded.Add(rev);
}
}

static public void RemoveRevealer (IFOWRevealer rev)
{
if (rev != null)
{
lock (mRemoved) mRemoved.Add(rev);
}
}

这个应该没啥好说的,子线程在处理这两个列表时同样需要加锁。
2)视野体使用IFOWRevelrs接口,方便模块隔离和扩展。同步问题这里采用了一种简单粗暴的方式,由于战争迷雾属于表现层面的东西,即使用于帧同步也不会有问题。
public interface IFOWRevealer
{
// 给FOWSystem使用的接口
bool IsValid();
Vector3 GetPosition();
float GetRadius();

// 给FOWLogic使用的接口,维护数据以及其有效性
void Update(int deltaMS);
void Release();
}
继承IFOWRevealer接口用来实现各种不同的视野体,本示例中给出了角色视野体与临时视野体的实现,其它视野体自行根据需要扩展。