游戏开发日志 (其四):战争迷雾

发表于: 7/18/2022

Unity

地图网格

战争迷雾

:::info 本文基于【基于视野的战争迷雾实现】Unity战争迷雾实现 @琴桌 实现。 Github :::

:::default 一个月的磕磕绊绊终于是完成了效果,总感觉既然当初察觉了掌握ECS的契机不如直接上手,现在纠结这些对象、类功能的划分实属难受。 旁人的打断,自己的摆烂,想到这里,不禁潸然。 :::

概述

总体上的思路为将地图按方块划分,将地图上的障碍物标记出来,对每个有视野的单位进行射线检测判断视野是否被遮挡,将方块化的视野转换为RenderTexture作为迷雾对象的材质渲染到地图的上方。

地图块

地图块定义

使用一个枚举代表一个地图块的状态

public enum MapStatus:byte{
    Empty,

    Obstacle,
    AllyUnit,
    EnemeyUnit,
    SelfUnit
}
// 整个地图的地图块
MapStatus[,] innerMap;
// 地图宽高
public int Width { get => innerMap.GetLength(0); }
public int Height { get => innerMap.GetLength(1); }
// 地图块的边长
public float GridSize { get; }

地图块索引

对地图的索引到真实位置的转换则有

public Vector2 GetRealPosition(int x, int y)
{
    return new Vector2(
            (x - Width / 2 + 0.5f) * GridSize,
            (y - Height / 2 + 0.5f) * GridSize
        );
}
public Vector2Int GetGridPosition(Vector2 position)
{
    return new Vector2Int(
            (int)((position.x / GridSize + Width / 2 - 0.5f)),
            (int)((position.y / GridSize + Height / 2 - 0.5f))
        );
}
public bool IsInMap(Vector2Int pos)
{
    return (pos.x >= 0 && pos.x <= Width) && (pos.y >= 0 && pos.y <= Height); 
}

地图块生成

对于地图,可以使用 Physics2D.OverlapBox()1 来判断地图块状态,使用了Solid Unit作为障碍物的Layer,排除其他单位碰撞体。

for (int i = 0; i < Width; i++)
    for (int j = 0; j < Height; j++)
    {
        var box = Physics2D.OverlapBox(GetRealPosition(i, j), new Vector2(GridSize, GridSize)/2, 0, LayerMask.GetMask("Solid Unit"));
        if (box != null)
        {
            innerMap[i, j] = MapStatus.Obstacle;
        }
        else innerMap[i, j] = MapStatus.Empty;
    }

单位视野

使用一个Behavior存储单位视野相关的数据

public class UnitVision : MonoBehaviour
{
    [SerializeField] // 这个 Attribute 可以让变量显示在 Inspector 窗口中
    float sightRange = 5;

    public float GetSight()
    {
        return sightRange;
    }
}

迷雾生成

首先归纳下现有代码,将地图块内代码均包装为MapGrid

public class MapGrid{
    // ...
}

基本设施

public class FogMaker : MonoBehaviour{
    // 地图网格
    MapGrid mapGrid;
    // 观察者
    List<UnitVision> viewers = new();
    // 迷雾颜色信息
    Color32[] colorBuffer;
    // 网格索引转换为颜色索引
    public int ColorIndex(int x, int y)
    {
        x = Math.Clamp(x, 0, mapGrid.Width);
        y = Math.Clamp(y, 0, mapGrid.Height);
        return x + y * mapGrid.Width;
    }

    public void Init(MapGrid map){
        // 初始化迷雾为不透明黑色
        colorBuffer = new Color32[mapGrid.Length];
        Array.Fill(colorBuffer, new Color32(0,0,0,255));
        // ... 
    }
    // ...
}

迷雾的计算

对每个观察者,获取视野范围内的方块

foreach (var v in viewers)
{
    var pos = mapGrid.GetGridPosition(v.transform.position);
    var sight_radius = v.GetSight() / mapGrid.GridSize;
    var sight_square = (int) (sight_radius * sight_radius);
    List<Vector2Int> curVisionPos = new();

    for(int i = - (int)sight_radius; i <= (int)sight_radius; i++)
        for(int j = - (int)sight_radius; j <= (int)sight_radius; j++)
        {
            if (i * i + j * j <= sight_square)
            {
                // 该坐标处于可视范围内 且处于地图内
                var p = pos + new Vector2Int(i, j);
                if(mapGrid.IsInMap(p))
                    curVisionPos.Add(p);
            }
        }
    
    // ...
}

对获取到的方块,将其与观察者连线,判断中间是否有障碍物遮挡,若有则从视野中移除。

// 由远到近排序
curVisionPos.Sort((a, b) => DistanceSquared(pos, b) - DistanceSquared(pos, a));

HashSet<Vector2Int> visited = new(), novision = new();
foreach (var p in curVisionPos)
{
    if (!visited.Contains(p))
    {
        var (blocked, unblocked) = GetBlockedAndUnblocked(pos,p);

        novision.UnionWith(blocked);
        visited.UnionWith(blocked);
        visited.UnionWith(unblocked);
    }
}
// 移除无视野
curVisionPos.RemoveAll(p => novision.Contains(p));
// 将视野区域设置为透明
foreach (var p in curVisionPos)
{
    colorBuffer[ColorIndex(p)].a = 0;
}

这里首先按照由远到近的距离将方块排序,提高后续计算效率,因为在计算遮挡时会将所有被遮挡和不被遮挡的方块返回,可以尽可能多的排除visited方块。

射线检测函数

由起点向终点逐方块检测是否有障碍物,若遇到障碍物则后续方块均为无视野的遮挡区域。

public (List<Vector2Int>, List<Vector2Int>) GetBlockedAndUnblocked(Vector2Int source, Vector2Int target)
{
    var blocked = new List<Vector2Int>();
    var unblocked = new List<Vector2Int>();

    var delta = target - source;
    // 起始位置相同
    if(delta.sqrMagnitude == 0)
    {
        if (mapGrid[source[0], source[1]].HasFlag(MapGrid.MapStatus.Obstacle))
            blocked.Add(source);
        else unblocked.Add(source);

        return (blocked, unblocked);
    }

    unblocked.Add(source);
    bool blockMet = false;
    // 判断 X 或 Y 作为基础步长方向
    if (Math.Abs(delta.x) > Math.Abs(delta.y))
    {// X 轴为方向
    
        // 符号
        int x_sign = Math.Sign(delta.x);
        // 增量
        var y_inc = (delta.y / (float) Math.Abs(delta.x));
        float step_y = y_inc;
        for (int step_x = x_sign;
            x_sign * step_x <= x_sign * delta.x;
            step_x+=x_sign, step_y+=y_inc)
        {
            var s = source + new Vector2Int(step_x, Mathf.RoundToInt(step_y));

            if (!blockMet)
            {
                unblocked.Add(s);
                blockMet = mapGrid[s].HasFlag(MapGrid.MapStatus.Obstacle);
            }
            else
                blocked.Add(s);
        }
    }
    else
    {
        // 方向为 Y 轴的情况与 X 轴基本相同,只需调换 x、y 的角色即可
        // ...
    }

    return (blocked, unblocked);
}

迷雾刷新

在区域丢失视野时重新覆盖一层半透明的已探索迷雾

public void RefreshFog()
{
    for(int i = 0; i < colorBuffer.Length; i++)
    {
        if (colorBuffer[i].a == 0)
            colorBuffer[i].a = 180;
    }
}

配合使用可以实现战争迷雾-(未)探索区域的效果。

迷雾贴图

:::info Shader、材质、纹理,属实头大住了,并没有完全理解此部分的流程,不过姑且实现了效果 :::

初始化资源

Material blurMaterial;
Texture2D textureBuffer;
RenderTexture renderTexture;
RenderTexture renderTexture2;
RenderTexture nextTexture;
RenderTexture currnetTexture;

public void Init(MapGrid map){
    // ... 
    blurMaterial = new Material(Shader.Find("ImageEffect/AverageBlur"));
    textureBuffer = new Texture2D(mapGrid.Width, mapGrid.Height, TextureFormat.ARGB32, false);
    renderTexture = RenderTexture.GetTemporary(mapGrid.Width, mapGrid.Height, 0);
    renderTexture2 = RenderTexture.GetTemporary(mapGrid.Width, mapGrid.Height, 0);

    nextTexture = RenderTexture.GetTemporary(mapGrid.Width, mapGrid.Height, 0);
    currnetTexture = RenderTexture.GetTemporary(mapGrid.Width, mapGrid.Height, 0);
}
// 在销毁时释放资源
private void OnDestroy()
{
    Release();
}
public void Release()
{
    RenderTexture.ReleaseTemporary(renderTexture);
    RenderTexture.ReleaseTemporary(renderTexture2);
    RenderTexture.ReleaseTemporary(currnetTexture);
    RenderTexture.ReleaseTemporary(nextTexture);
}

使用颜色信息生成 Texture,并进行几次模糊

public void MakeTextureAndBlur()
{
    textureBuffer.SetPixels32(colorBuffer);
    textureBuffer.Apply();

    Graphics.Blit(textureBuffer, renderTexture, blurMaterial, 0);
    
    Graphics.Blit(renderTexture, renderTexture2, blurMaterial, 0);
    Graphics.Blit(renderTexture2, renderTexture, blurMaterial, 0);

    Graphics.Blit(renderTexture, nextTexture);
}

缓动,在两帧迷雾间过渡

public void Lerp()
{
    Graphics.Blit(currnetTexture, renderTexture);
    blurMaterial.SetTexture("_LastTex", renderTexture);
    Graphics.Blit(nextTexture, currnetTexture, blurMaterial, 1);
}

在每次更新时更新迷雾

private void Update()
{
    RefreshFog();
    ComputeFog();
    MakeTextureAndBlur();
    Lerp();
}

对外提供 Texture 获取

public RenderTexture FogTexture { get => currnetTexture; }

迷雾渲染

获取渲染迷雾的对象的材质,每次更新时将生成的 FogTexture 赋予到材质即可。

public class FogRenderer : MonoBehaviour
{
    public GameObject fogRenderer;
    public FogMaker fogMaker;
    
    Material fogMaterial;
    void Start()
    {
        fogMaterial = fogRenderer.GetComponent<Renderer>().material;
    }
    // Update is called once per frame
    void Update()
    {
        if (fogMaker.FogTexture != null)
            fogMaterial.SetTexture("_MainTex", fogMaker.FogTexture);
    }
}

为 fogRenderer 添加如下组件

示例

:::info 在2D项目中使用Plane需要进行旋转,确保方向符合你的要求。

MeshRenderer的材质是使用Shader创建的,见 :::

效果展示

:::default 意外的实现了一个草丛呢…真是世事难料。 在本章节中的MapGrid可以基于此上进一步来实现寻路系统,敬请期待。 :::

Footnotes

  1. 对于3D项目,使用Physics.CheckBox()