Unity战争迷雾系统:从原理到高性能实现

发布时间:2026/7/8 16:21:42
Unity战争迷雾系统:从原理到高性能实现 1. 项目概述在即时战略RTS或多人在线战术竞技MOBA这类游戏中战争迷雾系统是塑造游戏策略深度和玩家体验的核心机制之一。它不仅仅是简单地遮挡地图而是动态地管理着玩家的信息获取权将“未知”与“已知”转化为可玩性。一个高效的战争迷雾系统需要在视觉上平滑自然在性能上稳定可靠尤其是在Unity引擎中面对大量单位、动态变化的视野以及移动端或PC端的性能限制时其实现挑战不小。很多开发者初次尝试时要么卡在性能瓶颈上导致游戏帧率骤降要么在视觉效果上不尽人意边缘锯齿严重过渡生硬。今天我就结合自己多年的项目实战经验从最底层的原理讲起一步步拆解如何在Unity中构建一个既高效又美观的战争迷雾系统涵盖从数据管理、多线程处理到Shader渲染的完整链路并分享那些在官方文档里找不到的优化技巧和避坑指南。2. 战争迷雾的核心原理与设计思路2.1 迷雾的三重状态定义战争迷雾的本质是对游戏世界信息的分层管理。我们通常将其划分为三个明确的状态这直接决定了后续的数据结构和渲染逻辑未探索区域玩家从未涉足或观察过的区域。在视觉上通常表现为纯黑色或不透明的迷雾完全隐藏其下的地形和单位。这是信息的绝对盲区。已探索区域玩家单位曾经拥有过视野但当前视野已离开的区域。视觉上常呈现为半透明的灰色或褪色状态可以显示地形和建筑等静态环境的“历史快照”但会隐藏当前动态的单位和变化。这是信息的“记忆”区域。当前视野玩家单位当前直接观察到的区域。视觉上完全清晰所有细节可见。这是信息的“实时”区域。这三个状态是层层递进的关系一个区域从“未探索”变为“当前视野”当视野离开后再降级为“已探索”。我们的系统核心就是高效地维护和更新一张能表达这三种状态的地图。2.2 主流实现方案对比与选型实现战争迷雾主流思路分为两大步生成一张代表迷雾状态的贴图然后将这张贴图渲染到屏幕上。每一步都有不同的技术选型直接影响着最终效果和性能。2.2.1 贴图生成方案网格化 vs. 连续化贴图生成的核心是将连续的游戏世界坐标离散化到一张纹理的像素上。常见方案有两种网格拼接法预先制作几种固定形状如圆形、扇形的迷雾贴图素材根据单位视野位置进行拼接。这种方法在早期硬件受限时常见但其局限性非常明显视野边缘锯齿感强不同视野叠加时过渡不自然难以实现平滑的动态变化。除非项目有特殊的像素美术风格要求否则不推荐。网格绘制法推荐将游戏世界划分为均匀的网格正方形或六边形每个网格对应纹理中的一个或一组像素。通过计算每个视野单位的影响范围来“点亮”或“探索”对应的网格。这是目前最主流、最灵活的方法。它的优势在于数据规整便于进行模糊、扩散等后期处理也易于与寻路网格等逻辑系统解耦。为什么选择正方形网格在大多数项目中我倾向于使用正方形网格。虽然六边形网格在距离计算上更精确从中心到六个相邻格子的距离相等能提供更圆滑的视野圆形但其计算复杂度更高数据结构更复杂Shader采样和模糊处理也更麻烦。正方形网格实现简单性能优异通过后续的高斯模糊完全可以弥补边缘的“阶梯”感在移动端和性能敏感的场景下是更务实的选择。2.2.2 屏幕渲染方案后处理 vs. 投影 vs. 模型贴图生成迷雾贴图后需要将其与游戏场景融合屏幕后处理在摄像机渲染完所有不透明和透明物体后通过一个全屏的Image Effect Shader将迷雾贴图叠加到最终画面上。这种方法与场景几何完全解耦实现简单。但缺点是需要全屏绘制且迷雾效果无法与场景中的物体进行深度交互例如单位走入迷雾时边缘可能不精确。摄像机投影将迷雾贴图作为一张投影纹理Projector投射到场景中。这种方式可以让迷雾贴合地形起伏效果更立体。但Unity的原生Projector组件性能开销较大且需要为所有接收投影的物体设置额外的Shader或渲染层管理起来较复杂。模型贴图推荐创建一个覆盖整个游戏世界的巨大平面或跟随摄像机移动的适口平面将迷雾贴图作为其材质的主纹理。通过一个自定义Shader根据纹理的RGB通道值来混合“未探索”、“已探索”和“当前视野”的颜色。这是性能、效果和灵活性平衡得最好的方案。它只需要渲染一个额外的平面开销可控并且可以在Shader中实现非常精细的颜色混合和边缘过渡。基于以上分析本指南将采用“正方形网格绘制法”生成迷雾贴图并结合“模型贴图法”进行屏幕渲染的技术路线。这套组合拳经过了多个项目的验证能在保证优秀视觉效果的同时将性能开销降至最低。3. 核心数据结构与多线程架构要实现“高效”关键在于将密集的CPU计算从主线程中剥离。迷雾贴图的更新遍历所有视野单位、计算影响网格、进行模糊处理是典型的计算密集型任务绝不能每帧都在主线程做。3.1 三层缓冲与状态机设计我们引入三个Color32[]数组作为核心数据缓冲区。Color32每个通道占用1字节非常适合存储0-255范围的可见度数据。protected Color32[] mBuffer0; // 用于最终提交给纹理和Shader的缓冲区包含连续两帧的状态 protected Color32[] mBuffer1; // 工作缓冲区用于计算当前帧的原始视野数据 protected Color32[] mBuffer2; // 临时缓冲区用于进行模糊计算为什么需要三个这是为了支持平滑过渡和线程安全。mBuffer1负责根据当前所有视野单位的位置计算出最新的“硬”视野图例如视野内为255外为0。mBuffer2作为mBuffer1模糊处理时的临时存储空间。mBuffer0是关键。它的RG通道存储上一帧的迷雾状态BA通道存储当前帧计算出的新状态。Shader通过一个混合因子_BlendFactor在RG和BA之间插值从而实现迷雾边界随时间平滑扩散或收缩的动画效果而不是生硬地跳变。为了协调主线程与工作线程我们定义一个简单的状态机public enum State { Blending, // 正在混合渲染中等待下一次更新触发 NeedUpdate, // 需要更新主线程通知工作线程开始计算 UpdateTexture, // 工作线程计算完成通知主线程更新纹理 }这个状态机清晰地划分了职责Blending时GPU在渲染NeedUpdate时主线程发现到了该更新的时间点设置标志工作线程看到NeedUpdate标志开始计算完成后设置为UpdateTexture主线程在下一帧看到UpdateTexture就将mBuffer0的数据上传到GPU纹理然后状态回归Blending。3.2 工作线程与主线程的同步策略多线程编程的核心是同步和避免竞争条件。我们的同步主要围绕两个点视野单位列表的同步和缓冲区状态的同步。视野单位列表同步游戏逻辑中单位会动态创建、销毁、移动。主线程需要将这些变化同步给负责计算迷雾的工作线程。我们使用带锁的列表来实现private static ListIFOWRevealer m_ActiveRevealers new ListIFOWRevealer(); private static ListIFOWRevealer m_ToAdd new ListIFOWRevealer(); private static ListIFOWRevealer m_ToRemove new ListIFOWRevealer(); private static readonly object m_ListLock new object(); // 主线程调用 public static void RegisterRevealer(IFOWRevealer revealer) { lock (m_ListLock) { m_ToAdd.Add(revealer); } } // 工作线程在每次计算前同步 private void SyncRevealerList() { lock (m_ListLock) { if (m_ToAdd.Count 0) { m_ActiveRevealers.AddRange(m_ToAdd); m_ToAdd.Clear(); } if (m_ToRemove.Count 0) { foreach (var r in m_ToRemove) { m_ActiveRevealers.Remove(r); } m_ToRemove.Clear(); } } }工作线程在每一轮计算开始时先获取锁将m_ToAdd和m_ToRemove中的变更应用到m_ActiveRevealers中。这样保证了工作线程使用的单位列表是相对一致的快照。缓冲区状态同步这就是通过前面提到的State枚举和定时器来控制的。主线程控制着更新频率例如每秒10次避免工作线程无节制地运行。工作线程大部分时间在休眠直到主线程将状态设为NeedUpdate。实操心得线程安全与性能平衡锁lock是保证线程安全的简单有效工具但频繁加锁会损害性能。在我们的设计里SyncRevealerList的调用频率与迷雾更新频率一致如10Hz而不是每帧调用这大大降低了锁竞争的开销。另外对mBuffer1和mBuffer2的读写完全在工作线程内部与主线程无关只有mBuffer0是共享的但主线程只在UpdateTexture状态时读取它而工作线程只在计算完并交换缓冲区后写入它这个时间点是错开的通过状态机隐式同步避免了显式锁。4. 迷雾贴图生成的核心算法详解4.1 视野揭示从世界坐标到纹理像素这是最核心的一步将一个三维世界中的圆形视野映射到二维的离散纹理网格上并标记为“可见”。void RevealCircle(IFOWRevealer revealer, float worldToTexScale) { Vector3 worldPos revealer.GetPosition(); float worldRadius revealer.GetRadius(); // 1. 转换到纹理空间 Vector2 texCenter new Vector2( (worldPos.x - m_WorldOrigin.x) * worldToTexScale, (worldPos.z - m_WorldOrigin.z) * worldToTexScale // 假设Y轴向上使用XZ平面 ); int texRadius Mathf.CeilToInt(worldRadius * worldToTexScale); // 2. 计算遍历边界方形包围盒减少计算量 int xMin Mathf.FloorToInt(texCenter.x - texRadius); int xMax Mathf.CeilToInt(texCenter.x texRadius); int yMin Mathf.FloorToInt(texCenter.y - texRadius); int yMax Mathf.CeilToInt(texCenter.y texRadius); // 3. 边界裁剪防止越界 xMin Mathf.Clamp(xMin, 0, m_TextureSize - 1); xMax Mathf.Clamp(xMax, 0, m_TextureSize - 1); yMin Mathf.Clamp(yMin, 0, m_TextureSize - 1); yMax Mathf.Clamp(yMax, 0, m_TextureSize - 1); int radiusSqr texRadius * texRadius; // 4. 遍历方形区域内的每个像素 for (int y yMin; y yMax; y) { int rowStartIndex y * m_TextureSize; // 预计算行起始索引避免在内部循环重复乘法 for (int x xMin; x xMax; x) { // 计算像素到圆心的距离平方 float dx x - texCenter.x; float dy y - texCenter.y; if (dx * dx dy * dy radiusSqr) { // 在圆形内标记为可见例如R通道设为255 int index x rowStartIndex; mBuffer1[index].r 255; } } } }关键优化点世界到纹理的缩放比例worldToTexScale这个值决定了纹理的精度。纹理大小 / 世界范围。比例越大一个像素代表的世界单位越小视野边缘越精细但纹理所需内存和计算量也越大。需要根据游戏世界大小和性能预算权衡。方形遍历与距离判断我们遍历一个包围圆形的方形区域而不是整个纹理这是最直接的性能优化。对区域内的每个像素用距离平方避免开方运算判断是否在圆内。行索引预计算在内层循环外计算rowStartIndex避免了y * m_TextureSize x中重复的乘法运算在遍历大量像素时能带来可观的性能提升。4.2 模糊处理软化生硬边缘直接计算出来的视野图边缘是锯齿状的因为像素是方形的。我们需要一个模糊Blur过程来软化边缘让视野到迷雾的过渡更自然。这里采用一个简单的均值模糊Box Blur它虽然不如高斯模糊效果平滑但计算量小得多。void BoxBlurBuffer(Color32[] source, Color32[] destination) { // 遍历纹理的每一个像素除了最外一圈边界 for (int y 1; y m_TextureSize - 1; y) { int rowCurrent y * m_TextureSize; int rowAbove (y - 1) * m_TextureSize; int rowBelow (y 1) * m_TextureSize; for (int x 1; x m_TextureSize - 1; x) { int centerIndex x rowCurrent; // 取周围9个点3x3区域的R通道值求和 int sum 0; sum source[x-1 rowAbove].r; // 左上 sum source[x rowAbove].r; // 上 sum source[x1 rowAbove].r; // 右上 sum source[x-1 rowCurrent].r; // 左 sum source[centerIndex].r; // 中 sum source[x1 rowCurrent].r; // 右 sum source[x-1 rowBelow].r; // 左下 sum source[x rowBelow].r; // 下 sum source[x1 rowBelow].r; // 右下 // 求平均值并写入目标缓冲区 destination[centerIndex].r (byte)(sum / 9); } } // 处理边界可以将边界像素复制内部像素的值或者进行特殊处理如不模糊。 // 简单起见这里可以留空边界在后续Shader中可能被Clamp采样模式处理。 }这个模糊操作会将视野的硬边界扩散开形成一个从完全可见255到完全不可见0的灰度渐变带。你可以通过多次迭代模糊来获得更宽的过渡带但代价是性能线性增长。4.3 “已探索”状态的持久化“已探索”区域需要被记住。实现方法是在每次模糊计算完成后遍历整个mBuffer1比较每个像素的“当前视野”值R通道和“历史已探索”值G通道。如果当前视野值大于历史已探索值则更新历史值。这样一旦一个区域被照亮过它的“已探索”状态就会被永久或半永久地记录在G通道中。void UpdateExploredState() { for (int i 0; i mBuffer1.Length; i) { // 如果当前可见度高于已探索记录则更新已探索记录 if (mBuffer1[i].r mBuffer1[i].g) { mBuffer1[i].g mBuffer1[i].r; } // 可选添加衰减逻辑让已探索区域随时间慢慢“遗忘”变暗 // else if (mBuffer1[i].g 0) // { // mBuffer1[i].g (byte)Mathf.Max(0, mBuffer1[i].g - decayRate); // } } }将mBuffer1的R和G通道分别拷贝到mBuffer0的B和A通道代表新状态而mBuffer0的R和G通道保留上一帧的旧状态。这样mBuffer0就承载了新旧两帧的完整信息。5. Shader渲染与视觉平滑过渡贴图数据准备就绪后最后一步是通过Shader将其视觉化。我们创建一个使用Unlit Shader的平面Mesh覆盖摄像机视口。5.1 着色器代码解析Shader的核心任务是采样迷雾纹理并根据混合因子在旧状态和新状态之间插值最终输出对应的颜色。Shader Custom/FogOfWar { Properties { _MainTex (Fog Texture, 2D) black {} _BlendFactor (Blend Factor, Range(0, 1)) 0 _UnexploredColor (Unexplored Color, Color) (0,0,0,1) _ExploredColor (Explored Color, Color) (0.3,0.3,0.3,0.8) _FogAlpha (Global Fog Alpha, Range(0,1)) 0.9 } SubShader { Tags { QueueTransparent100 RenderTypeTransparent IgnoreProjectorTrue } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; float _BlendFactor; fixed4 _UnexploredColor; fixed4 _ExploredColor; float _FogAlpha; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { // 采样迷雾纹理 half4 fogData tex2D(_MainTex, i.uv); // 从纹理中解包数据旧状态(RG)、新状态(BA) half oldVisibility fogData.r; // 上一帧“视野”值 half oldExplored fogData.g; // 上一帧“已探索”值 half newVisibility fogData.b; // 当前“视野”值 half newExplored fogData.a; // 当前“已探索”值 // 使用_BlendFactor在旧状态和新状态之间线性插值 half finalVisibility lerp(oldVisibility, newVisibility, _BlendFactor); half finalExplored lerp(oldExplored, newExplored, _BlendFactor); // 根据最终值在未探索色和已探索色之间插值得到基础颜色 fixed4 color lerp(_UnexploredColor, _ExploredColor, finalExplored); // 最终透明度已探索区域有基础透明度视野区域完全透明 (1 - finalVisibility) color.a (1.0 - finalVisibility) * color.a * _FogAlpha; return color; } ENDCG } } }关键参数解析_BlendFactor由C#脚本每帧更新如Mathf.Clamp01(_BlendFactor Time.deltaTime / blendDuration)。它从0变化到1驱动着迷雾边界的平滑动画。当它为0时显示旧状态为1时显示新状态中间值则是平滑过渡。_UnexploredColor和_ExploredColor定义了迷雾的视觉风格。通常未探索是纯黑已探索是深灰或半透明的其他颜色。finalVisibility决定了某点是否在“当前视野”内。值为1255/255代表完全在视野内透明度为0完全透明值为0代表在视野外采用color.a的透明度。finalExplored决定了颜色的基调。值为1代表已探索使用_ExploredColor值为0代表未探索使用_UnexploredColor。5.2 渲染设置与性能考量渲染队列Queue设置为Transparent100确保这个迷雾平面在所有不透明和标准透明物体之后渲染正确叠加在场景之上。混合模式BlendSrcAlpha OneMinusSrcAlpha是标准的Alpha混合用于实现半透明效果。深度写入ZWrite关闭。因为迷雾平面是覆盖全屏的UI式效果不需要写入深度缓冲避免不必要的深度测试开销和可能的渲染排序问题。剔除Cull关闭。确保平面双面都渲染。在C#脚本中我们需要在Update中更新_BlendFactor并在合适的时机如LateUpdate或通过消息将mBuffer0的数据用Texture2D.SetPixelData上传到_MainTex。为了极致性能创建纹理时应使用TextureFormat.RGBA32或TextureFormat.ARGB32这类不压缩的格式因为我们需要频繁地CPU写、GPU读压缩格式会带来编解码开销。同时设置Texture.wrapMode为Clamp防止边缘采样时出现重复的迷雾图案。6. 高级优化技巧与常见问题排查6.1 性能优化实战指南纹理分辨率与更新频率的权衡这是最重要的两个性能杠杆。512x512的纹理比1024x1024的纹理像素数少75%模糊计算量也按比例下降。同样将迷雾更新频率从每秒30次降到10次能直接减轻CPU负担。需要通过Profiler测试找到画面质量和性能的平衡点。对于大地图可以考虑使用分层级的迷雾系统或者动态调整远离摄像机的区域的更新频率和精度。基于距离的视野计算优化不是所有视野单位都需要每帧进行精确的圆形遍历。对于距离很远的单位或者静止的单位可以降低其视野更新的频率。甚至可以引入“脏矩形”算法只更新视野发生变化的区域但这会显著增加逻辑复杂度。Shader复杂度最小化我们的Shader已经非常精简只做了一次纹理采样和两次lerp运算。确保没有在Shader中进行循环、分支或复杂的数学运算。避免使用discard操作。对象池管理视野单位频繁地创建和销毁IFOWRevealer对象会产生GC垃圾回收压力。使用对象池来管理这些对象可以有效地减少GC触发的频率保持帧率稳定。使用Job System与Burst CompilerUnity现代方案对于追求极致性能的项目可以考虑用Unity的C# Job System和Burst Compiler来重写迷雾计算循环。它们能利用多核CPU和SIMD指令将计算性能提升一个数量级。但这需要更深入的多线程编程知识和对Unity DOTS生态的了解。6.2 常见问题与解决方案速查表问题现象可能原因排查与解决方案迷雾边缘有尖锐的锯齿或方格感1. 纹理分辨率过低。2. 模糊迭代次数不够或模糊算法太简单。3. Shader中插值计算错误。1. 适当提高纹理大小。2. 增加Box Blur的迭代次数如从1次增加到2-3次或改用高斯模糊性能代价更高。3. 检查_BlendFactor是否在0-1之间平滑变化检查Shader中lerp函数使用是否正确。迷雾更新导致游戏卡顿1. 更新频率过高。2. 纹理分辨率过高。3. 视野单位过多计算循环耗时太长。4. 主线程与工作线程同步有阻塞。1. 使用Profiler的CPU模块定位耗时方法。降低updateFrequency。2. 降低纹理分辨率。3. 优化RevealCircle循环使用更紧凑的数据结构如NativeArray如果用了Jobs。对静止单位跳过计算。4. 检查锁的粒度确保lock内的代码执行速度极快。迷雾贴图出现闪烁或撕裂1. 缓冲区状态同步错误主线程和工作线程同时读写同一缓冲区。2. 纹理上传时机不对可能在渲染中途纹理被更改。1. 仔细检查状态机逻辑确保mBuffer0只在UpdateTexture状态由主线程读取并上传。工作线程写入mBuffer0必须在状态变为UpdateTexture之前完成。2. 确保在Camera.OnPreRender或类似的渲染回调之前完成纹理的Apply()。已探索区域不会持久化“已探索”状态更新逻辑有误或者G通道的数据没有被正确传递到最终渲染的纹理中。1. 检查UpdateExploredState函数逻辑确保是mBuffer1[i].r mBuffer1[i].g时才更新g。2. 检查MergeBuffer或类似函数确保mBuffer1的g通道被正确拷贝到了用于渲染的缓冲区如mBuffer0的a通道。3. 在Shader中检查是否正确采样了a通道作为finalExplored。迷雾平面遮挡了UI或其他特效迷雾平面的渲染队列设置不正确或者Layer被错误遮挡。1. 确保迷雾平面的Shader渲染队列为Transparent100或更高的值。2. 检查摄像机的Culling Mask确保UI相机不渲染迷雾平面层。移动端发热严重耗电快即使帧率稳定持续的CPU多线程计算和GPU渲染也会增加功耗。1. 在移动平台进一步降低更新频率和纹理分辨率。2. 考虑在设备发热时动态降级效果例如关闭模糊或降低混合动画的帧率。3. 使用SystemInfo.batteryStatus监测电量在低电量模式下启用更省电的配置。6.3 模块化与扩展性设计一个好的战争迷雾系统应该易于集成和扩展。我们通过IFOWRevealer接口实现了与游戏逻辑的解耦。任何游戏对象只要实现这个接口就能成为视野源。public interface IFOWRevealer { /// summary /// 该视野源是否有效如单位是否死亡 /// /summary bool IsValid(); /// summary /// 获取世界空间位置 /// /summary Vector3 GetPosition(); /// summary /// 获取视野半径 /// /summary float GetRadius(); /// summary /// 获取视野类型可扩展用于区分永久视野、临时技能视野等 /// /summary int GetVisionType(); }你可以基于此扩展出UnitRevealer绑定在普通单位上随单位移动。BuildingRevealer绑定在建筑上提供静态视野。SkillRevealer由技能临时创建在一段时间后自动销毁用于实现“侦察守卫”、“鹰眼术”等效果。TerrainRevealer绑定在某些地形区域提供永久性的地图揭示如开局显示的资源点。通过这种设计游戏逻辑只需要在单位创建、销毁或技能释放时调用FOWSystem.RegisterRevealer()和RemoveRevealer()即可迷雾系统会自动处理剩下的所有事情包括多线程更新和渲染真正做到了高内聚、低耦合。这套系统经过精心调优即便是在上百个单位同屏的中型RTS游戏中也能保持流畅的运行帧率其核心思想——将密集计算卸到工作线程主线程只负责调度和渲染——是Unity高性能游戏开发中值得深入掌握的通用模式。