type
status
date
slug
summary
tags
category
icon
password
一、为什么迷雾效果值得深入研究
战争迷雾(Fog of War)是策略游戏里最经典的机制之一:地图一开始是黑的,角色走过的地方变灰,当前视野范围内才能看清。
这个效果表面上很简单,实现起来涉及的问题出乎意料地多:视野怎么算?障碍物遮挡怎么处理?探索历史怎么保存?画面怎么平滑过渡?
我读了 FogOfWar_ForUnity 这个开源仓库,把它的设计过了一遍,理解了大部分思路,但也发现一些可以换个方式做的地方——于是决定自己写一个版本。
二、读懂这个仓库:它是怎么做的
数据层:用 Color32 的两个通道编码三种状态
迷雾本质上是一个覆盖全地图的二维数组,每个格子存一个状态:
状态 | 含义 |
未探索 | 完全黑暗 |
已探索但不可见 | 半透明灰雾 |
当前可见 | 完全透明 |
这个仓库用
Color32 的两个通道编码这三种状态:r == 255:当前帧可见
b == 255:历史上曾经可见(已探索)
每帧开始时把
r 清零,保留 b,这样已探索信息永不丢失。这个设计很精妙——用一个数组同时管历史状态和当前帧状态,省了一个数组。视野层:圆形范围 + 障碍物锥形遮挡
先把圆形范围内所有格标记为可见,然后找出范围内的障碍物,从每个障碍物向后投射"阴影锥",把被遮挡的格取消可见。
遮挡判断没有用 Raycast,而是用斜率近似角度:
var k1 = y1 * 1f / x1; // 障碍物方向斜率
var k2 = y2 * 1f / x2; // 目标格方向斜率
float z = Mathf.PI / (6 + distance / 1.2f); // 遮挡角随距离缩小
return Angle(k1, k2) < z; // 角度差 < 遮挡角 → 被遮挡这样做的好处是避免三角函数开销,性能更好。代价是边缘处有近似误差。
渲染层:CPU → GPU → 模糊 → 帧间插值
整条渲染管线:
- CPU 算出每格的 alpha 值(透明/半透明/不透明)
- 上传到 Texture2D,再 Blit 做三次 3×3 高斯模糊,让边缘柔和
- 每帧
lerp(上一帧纹理, 本帧纹理, 0.05),让迷雾扩散/消散有平滑动画
- 把最终纹理贴到一个铺在场景上方的透明 Plane
三、我的思考:哪里可以做得不一样
问题一:斜率近似的误差
斜率
k = y/x 在 x = 0 时会产生除零,作者用了 x1 == 0 的特判,但整体逻辑比较难读。用角度(atan2)更直观,现代 GPU 和 CPU 做三角函数的开销其实很小。问题二:渲染层用 Plane 覆盖
用一个 Plane 铺在场景上方,需要精确调整 Plane 的大小和 UV 映射,换场景时容易对不齐。更稳的方式是用后处理(Post-Processing)直接在屏幕空间叠加迷雾纹理,不依赖世界坐标的对齐。
问题三:视野更新用定时器(0.5s 一次)
这能省开销,但在角色快速移动时会有明显延迟感。可以用脏标记(dirty flag):角色移动时标记需要重算,下一帧再更新,既不丢精度又避免每帧都算。
四、自己实现:从零搭一个战争迷雾
整体设计
[FogMap] → 地图数据,每格存三态(byte数组)
[FogViewer] → 挂在角色上,提供位置和视野半径
[FogManager] → 每帧协调更新,写数据、上传纹理
[FogShader] → 后处理叠加,根据屏幕 UV 采样迷雾纹理第一步:地图数据结构
用
byte 数组,每格三种状态,比 Color32 更直观:public class FogMap
{
public enum FogState : byte { Hidden = 0, Explored = 1, Visible = 2 }
private FogState[] _states;
private int _width, _height;
public FogMap(int width, int height)
{
_width = width;
_height = height;
_states = new FogState[width * height];
}
public FogState Get(int x, int y) => _states[y * _width + x];
public void Set(int x, int y, FogState s) => _states[y * _width + x] = s;
// 每帧开始:把所有 Visible 降级为 Explored(保留探索历史)
public void BeginFrame()
{
for (int i = 0; i < _states.Length; i++)
if (_states[i] == FogState.Visible)
_states[i] = FogState.Explored;
}
}第二步:视野计算——圆形范围 + DDA 射线
DDA(Digital Differential Analyzer)是一种整数格子上的射线追踪算法,不用浮点三角函数,速度快且无歧义:
public void ComputeVision(FogMap map, Vector2Int center, int radius)
{
// 圆形范围内,向每个边缘格发出射线
for (int angle = 0; angle < 360; angle += 2) // 每2度一条射线
{
float rad = angle * Mathf.Deg2Rad;
float dx = Mathf.Cos(rad);
float dy = Mathf.Sin(rad);
float x = center.x, y = center.y;
for (int step = 0; step <= radius; step++)
{
int gx = Mathf.RoundToInt(x);
int gy = Mathf.RoundToInt(y);
if (gx < 0 || gy < 0 || gx >= map.Width || gy >= map.Height) break;
map.Set(gx, gy, FogMap.FogState.Visible);
// 遇到障碍物则停止这条射线
if (IsObstacle(gx, gy)) break;
x += dx;
y += dy;
}
}
}射线密度(每 2 度一条)根据视野半径调整。半径大时可能漏格,可以改为角度步长= 1 / radius(每格一条射线)来保证不漏。
第三步:把数据上传到纹理
private Texture2D _fogTexture;
private Color32[] _pixelBuffer;
void UploadToTexture(FogMap map)
{
for (int i = 0; i < _pixelBuffer.Length; i++)
{
byte alpha = map.States[i] switch
{
FogMap.FogState.Visible => 0, // 透明,完全可见
FogMap.FogState.Explored => 120, // 半透明,灰雾
_ => 255 // 不透明,全黑
};
_pixelBuffer[i] = new Color32(0, 0, 0, alpha);
}
_fogTexture.SetPixels32(_pixelBuffer);
_fogTexture.Apply();
}第四步:Shader——后处理叠加迷雾
用 Unity 的后处理框架,在屏幕空间叠加,不需要世界坐标对齐:
Shader "Custom/FogOverlay"
{
Properties { _FogTex ("迷雾纹理", 2D) = "black" {} }
SubShader
{
Tags { "Queue" = "Overlay" "RenderType" = "Transparent" }
ZTest Off
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
CGPROGRAM
#pragma vertex vert_img
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _FogTex;
fixed4 frag(v2f_img i) : SV_Target
{
// 把摄像机视口 UV 映射到迷雾纹理 UV
// 需要传入地图边界和摄像机位置来做偏移
float2 fogUV = WorldToFogUV(i.uv);
return tex2D(_FogTex, fogUV);
}
ENDCG
}
}
}第五步:帧间平滑过渡
直接改纹理会让迷雾瞬间出现/消失,加一个 Lerp 让过渡更自然:
private RenderTexture _currentFogRT;
private RenderTexture _targetFogRT;
private Material _lerpMat; // 只有一行:lerp(_LastTex, _MainTex, _T)
void LerpFog()
{
_lerpMat.SetTexture("_LastTex", _currentFogRT);
_lerpMat.SetTexture("_MainTex", _targetFogRT);
_lerpMat.SetFloat("_T", 0.08f); // 比原仓库的 0.05 稍快,响应更及时
var temp = RenderTexture.GetTemporary(_currentFogRT.descriptor);
Graphics.Blit(null, temp, _lerpMat);
Graphics.Blit(temp, _currentFogRT);
RenderTexture.ReleaseTemporary(temp);
}五、总结:学到了什么
数据设计影响一切后续逻辑。 开源仓库用 Color32 双通道编码三态,聪明但难读;用独立 byte 数组更直观,也方便后续扩展(比如加"部分可见"状态)。没有绝对的对错,取决于优先考虑性能还是可维护性。
视野计算的核心是"遮挡"。 圆形范围简单,障碍物遮挡才是难点。射线方法和锥形阴影各有取舍:射线更精确,锥形更快。理解这个权衡之后,遇到同类问题(光照、声音传播)都能用类似思路处理。
渲染和数据要分层。 数据层(byte 数组)负责状态正确,渲染层(纹理 + Shader)负责视觉效果。两层解耦之后,可以独立优化——比如把数据计算挪到 ComputeShader,而不需要改渲染逻辑。
帧间插值是廉价的视觉提升。 一行
lerp 让硬切变成平滑过渡,开销几乎为零,但视觉质量提升明显。这种"用时间换空间感"的思路在游戏开发里到处适用。六、发散:还能延伸到哪里
方向1 - Compute Shader 并行计算
视野的每条射线相互独立,天然适合并行。把视野计算放到 Compute Shader,几千条射线同时算,对大地图的性能提升非常显著。
方向2 - 动态障碍物
现在的实现假设障碍物是静态的(初始化时用物理检测)。如果要支持动态障碍物(比如可移动的箱子),需要每帧重建障碍物 mask,或者用事件驱动的增量更新。
方向3 - 多单位视野合并
多个单位的视野取并集。可以让每个单位独立维护一张迷雾纹理,GPU 上用
max 操作合并;也可以在 CPU 上用位运算合并 byte 数组。位运算版本:state[i] = max(stateA[i], stateB[i]),一行搞定。方向4 - 视野形状扩展
除了圆形视野,还可以做扇形(带朝向的前方视野,适合潜行游戏)、矩形(摄像机视角,适合监控场景)。只需要修改"哪些格子在视野内"的筛选条件,遮挡逻辑完全不用改。
方向5 - 接入 AI
把迷雾数据暴露给 AI 系统:AI 单位只能"看到"当前可见格里的敌人,已探索区域可以记忆上次看到的位置。迷雾不只是视觉效果,还是信息层。
- 作者:lzzd
- 链接:https://lazy-zed.com/article/u3d_6
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。


.png?table=block&id=19885e12-7d7c-8090-b2f9-d59fd470ae2a&t=19885e12-7d7c-8090-b2f9-d59fd470ae2a)






