Unity战争迷雾系统:从基础原理到进阶优化实战指南
1. 项目概述战争迷雾在Unity中的核心价值与挑战在策略游戏、RTS即时战略或者一些带有探索元素的RPG中战争迷雾是一个能极大提升游戏沉浸感和策略深度的核心机制。它模拟了现实战争中“未知区域”的概念玩家只能看到己方单位视野范围内的地图其余区域则被一层“迷雾”所笼罩隐藏了敌方的动向和地图细节。这个机制直接决定了游戏的侦察、偷袭、资源控制等核心玩法循环。对于Unity开发者而言实现一套高效、美观且可扩展的战争迷雾系统是一个兼具挑战性和成就感的任务。它横跨了渲染、性能优化、数据结构和游戏逻辑等多个领域。一个基础的战争迷雾可能只需要一张遮罩纹理但一个“进阶”的完整方案则需要考虑如何在移动端保持60帧、如何支持超大地图、如何实现平滑的动态迷雾边缘、如何与复杂的单位视野如空中单位、潜行单位结合以及如何优雅地集成到现有的URP/HDRP管线中。网上有很多零散的教程有的教你用Shader画个圈有的教你用RenderTexture做混合。但很多朋友照着做下来会发现要么性能堪忧在手机上跑不动要么效果生硬迷雾边缘像狗啃的一样要么扩展性差想加个“永久可见区域”比如已探索过的建筑的功能就无从下手。这正是因为战争迷雾不是一个孤立的特效而是一个需要从底层数据到顶层渲染全链路设计的系统。今天我就结合自己踩过的坑和项目实战经验从最基础的原理讲起一步步拆解出一个能在生产环境中使用的、从基础到进阶的完整Unity战争迷雾实现方案。我们会涵盖数据层、逻辑层、渲染层并深入性能优化目标是让你不仅能复现更能理解每一个设计决策背后的“为什么”。2. 战争迷雾系统的核心架构设计在动手写代码之前我们必须先想清楚整个系统的骨架。一个健壮的战争迷雾系统通常可以分为三个核心层次数据层、逻辑层和渲染层。三层各司其职通过清晰的接口通信这是保证系统可维护和可扩展的关键。2.1 数据层迷雾信息的存储与表示数据层是系统的基石它负责存储整张地图的“可见性状态”。最直观的想法就是用一张二维数组或者一张纹理Texture来表示地图每个像素或数组元素代表地图上一个点的可见状态。基础方案单通道纹理作为迷雾遮罩最简单的方式是使用一张RenderTexture格式通常为R8_UNORM一个8位无符号规范化通道足够存储0-1的范围。其中0代表完全不可见黑色浓雾1代表完全可见白色无雾0到1之间的值则可以表示“半透明”的迷雾用于实现平滑边缘。// 创建迷雾遮罩纹理的示例 fogMaskTexture new RenderTexture(mapWidth, mapHeight, 0, RenderTextureFormat.R8); fogMaskTexture.filterMode FilterMode.Bilinear; // 用于平滑插值 fogMaskTexture.wrapMode TextureWrapMode.Clamp; fogMaskTexture.Create();这个fogMaskTexture就是我们的核心数据。但直接用它参与逻辑判断比如“某点是否可见”效率很低因为从GPU纹理回读到CPU是阻塞操作非常慢。进阶方案双缓冲与CPU端数据镜像为了解决上述问题我们引入“双缓冲”机制。除了GPU端的fogMaskTexture我们在CPU端维护一个结构相同的二维数组fogDataArray如byte[,]或float[,]。所有逻辑计算如单位视野更新都在这个CPU数组上进行计算完成后再将整个数组的数据一次性更新到GPU纹理中。这样逻辑判断是快速的CPU内存访问而渲染则使用最新的GPU纹理。注意对于超大地图fogDataArray可能会很大。可以考虑分块Chunk管理只更新视野范围内的数据块这是大型RPG或开放世界游戏的常用优化手段。状态细分从二值到多值基础的战争迷雾只有“已探索”和“未探索”两种状态。但更复杂的系统需要三种状态当前可见正在被己方单位视野覆盖完全清晰。已探索但不可见曾经被探索过比如你造过建筑的地方但现在没有单位在附近地图地形可见但动态单位敌人和资源变化被隐藏。未探索完全被黑色迷雾覆盖什么也看不见。 为了实现这个我们可以使用两个通道的纹理如RG16_UNORMR通道存储“永久探索状态”已探索过即为1G通道存储“当前可见状态”。在Shader中混合这两个通道就能呈现出丰富的视觉效果。2.2 逻辑层视野计算与数据更新逻辑层的任务是根据场景中所有具有视野的单位如士兵、侦察车、建筑的位置和视野范围实时计算出地图上每个点的可见性并更新数据层。基础视野算法圆形覆盖每个单位最简单的视野模型是一个圆形。我们需要将这个圆形覆盖的区域在fogDataArray中对应的位置标记为可见。最朴素的方法是遍历圆形包围盒内的所有像素计算其到圆心的距离如果小于视野半径则标记为1。void UpdateFogForUnit(Vector3 worldPos, float visionRadius) { Vector2Int centerGrid WorldToGrid(worldPos); int radiusInGrid Mathf.CeilToInt(visionRadius / gridSize); for (int dx -radiusInGrid; dx radiusInGrid; dx) { for (int dy -radiusInGrid; dy radiusInGrid; dy) { Vector2Int gridPos centerGrid new Vector2Int(dx, dy); if (IsInsideGrid(gridPos)) { float dist Vector2.Distance(centerGrid, gridPos) * gridSize; if (dist visionRadius) { fogDataArray[gridPos.x, gridPos.y] 1; // 标记为可见 } } } } }这个方法在小地图和单位不多时可行但效率是O(N * R²)N是单位数R是半径格子数性能压力会随着单位增多而急剧上升。进阶优化JobSystem与Burst编译器这是Unity提供给我们的性能利器。我们可以将视野计算这种高度并行、纯数据操作的任务交给JobSystem并利用Burst编译器将其编译成接近原生代码的高效程序。定义Job结构创建一个IJobParallelFor或IJob结构将fogDataArray以NativeArray的形式传入。并行化计算将地图网格分片或者将单位列表分片让多个工作线程同时计算不同单位或不同区域的视野影响。使用Burst编译为Job添加[BurstCompile]属性让Unity在编译时进行深度优化。 这样做的好处是能充分利用多核CPU将视野计算的时间消耗降低一个数量级对于上百个单位同时更新视野的场景至关重要。实操心得使用NativeArray时要特别注意内存管理。必须在Job完成后或对象销毁时调用Dispose()方法释放原生内存否则会造成内存泄漏。建议在OnDestroy或专门的清理函数中统一处理。复杂视野模型扇形、射线与障碍物圆形视野只是开始。实战中单位视野可能受方向限制扇形如《星际争霸2》中的攻城坦克架起状态也可能被地形和障碍物阻挡如《魔兽争霸3》中树林后的单位看不见。扇形视野在距离判断基础上增加一个角度判断。计算目标点相对于单位朝向的方向向量用Vector3.Angle或点积运算判断是否在扇形角度内。射线阻挡视线Line of Sight这是最大的性能挑战。一个经典方法是使用光线投射Raycasting。从单位中心向视野边缘的若干采样点发射射线物理射线或网格遍历检测是否击中阻挡层LayerMask。但逐像素发射射线成本极高。优化方案距离场Distance Field或预计算预计算对于静态障碍物如地图上的山体、不可摧毁的建筑可以预先计算好每个格子的“可见性”。这需要离线工具但运行时开销极小。简化射线不逐像素检测而是将视野圆环等分成若干角度如每10度一条每条射线只检测一次碰撞。然后根据碰撞点距离近似填充一个扇形区域。虽然精度有损失但性能提升巨大在大多数游戏中视觉上可以接受。使用ECS架构对于超大规模单位如千军万马可以考虑Unity的ECS实体组件系统架构将视野计算作为另一个高度并行的Job与渲染、移动等系统完全解耦达到终极性能。2.3 渲染层将数据转化为屏幕效果数据层有了最新的可见性信息逻辑层也在持续更新它最后一步就是如何把这些信息漂亮地画在屏幕上。这就是渲染层的工作。基础渲染屏幕后处理Post-processing这是最主流、最灵活的方式。其核心思路是在相机完成所有不透明和透明物体渲染之后在最终的屏幕图像上叠加一层根据迷雾遮罩纹理生成的半透明颜色。生成遮罩纹理将逻辑层计算好的fogDataArray数据通过Graphics.Blit或CommandBuffer更新到fogMaskTextureRenderTexture中。这里可能还需要一个模糊处理如高斯模糊让迷雾边缘变得平滑而不是生硬的锯齿。编写后处理Shader创建一个后处理Shader在Fragment Shader中采样屏幕颜色_MainTex即游戏画面。根据当前像素的屏幕UV采样fogMaskTexture得到该点的可见性强度如0到1。根据可见性强度在屏幕颜色和迷雾颜色如深蓝色或黑色之间进行插值混合lerp。对于“已探索但不可见”区域可以采样另一张“已探索纹理”可能是去饱和度的地形图并与迷雾颜色进行二次混合。集成到URP/HDRP在URP中你需要创建一个ScriptableRendererFeature和对应的ScriptableRenderPass。在Execute方法中设置渲染目标并调用CommandBuffer.Blit执行你的迷雾Shader。这样可以完美嵌入URP的渲染管线兼容其他后处理效果如Bloom、Color Grading。Shader核心代码示例URP Shader Graph思路虽然代码更灵活但用Shader Graph能更直观地理解流程UV采样获取屏幕空间UV。采样迷雾遮罩使用Sample Texture 2D节点纹理输入为fogMaskTextureUV使用屏幕UV。输出一个单通道值R。颜色混合使用Lerp节点。A端口输入迷雾颜色B端口输入屏幕颜色通过Sample Scene Color节点获取T端口输入上一步采样得到的迷雾强度。这样迷雾强度为0时输出迷雾颜色为1时输出原屏幕颜色。添加已探索效果可以再引入一张纹理exploredMaskTexture和一个颜色如灰色地形色。通过第二个Lerp节点将上一步的结果与“已探索颜色”进行混合混合系数由exploredMaskTexture采样值控制。进阶渲染技巧迷雾动画为了让静态的迷雾看起来有流动感可以在Shader中对采样UV加上基于时间的偏移Time节点或者采样一张噪声图Noise Texture进行扰动。注意幅度要小避免影响游戏性。多层迷雾与高度雾可以模拟“低空迷雾”效果。在Shader中引入像素的世界空间Y坐标Y值低于某个阈值的部分即使可见性为1也混合一些雾效。这需要从深度纹理重建世界坐标稍微复杂一些。性能考量后处理Shader应尽量简单避免分支和循环。确保fogMaskTexture的过滤模式FilterMode设置为Bilinear以获得平滑的插值避免像素感。3. 从零开始基础战争迷雾实现步骤理论讲了不少现在我们来动手实现一个最基础的、带平滑边缘的圆形战争迷雾。我们会按照数据、逻辑、渲染的顺序进行。3.1 步骤一创建与管理迷雾数据首先我们创建一个单例管理器FogOfWarManager来统筹一切。using UnityEngine; using UnityEngine.Rendering; public class FogOfWarManager : MonoBehaviour { public static FogOfWarManager Instance; [Header(Fog Settings)] public int textureWidth 256; public int textureHeight 256; public float worldSize 100f; // 迷雾覆盖的世界空间大小 private float gridSize; // 每个纹理像素对应的世界大小 private Texture2D fogDataCPU; // CPU端数据镜像 private RenderTexture fogMaskTexture; // GPU端遮罩纹理 private Material fogPostProcessMaterial; // 后处理材质 public RenderTexture FogMaskTexture fogMaskTexture; private void Awake() { Instance this; gridSize worldSize / textureWidth; InitializeFogData(); CreatePostProcessMaterial(); } private void InitializeFogData() { // 1. 创建CPU端数据初始化为0全黑 fogDataCPU new Texture2D(textureWidth, textureHeight, TextureFormat.R8, false); Color[] initialPixels new Color[textureWidth * textureHeight]; for (int i 0; i initialPixels.Length; i) { initialPixels[i] Color.black; // R通道为0 } fogDataCPU.SetPixels(initialPixels); fogDataCPU.Apply(); // 2. 创建GPU端RenderTexture fogMaskTexture new RenderTexture(textureWidth, textureHeight, 0, RenderTextureFormat.R8); fogMaskTexture.filterMode FilterMode.Bilinear; fogMaskTexture.wrapMode TextureWrapMode.Clamp; fogMaskTexture.Create(); // 3. 将初始数据拷贝到GPU Graphics.Blit(fogDataCPU, fogMaskTexture); } private void CreatePostProcessMaterial() { // 假设我们有一个名为“FogPostProcess”的Shader Shader fogShader Shader.Find(Hidden/FogPostProcess); if (fogShader null) { Debug.LogError(Fog PostProcess Shader not found!); return; } fogPostProcessMaterial new Material(fogShader); fogPostProcessMaterial.SetTexture(_FogMaskTex, fogMaskTexture); fogPostProcessMaterial.SetColor(_FogColor, Color.black); } // 将世界坐标转换为纹理UV坐标 public Vector2 WorldToUV(Vector3 worldPos) { Vector2 uv new Vector2( (worldPos.x worldSize * 0.5f) / worldSize, (worldPos.z worldSize * 0.5f) / worldSize // 假设地图在XZ平面 ); return Vector2.ClampMagnitude(uv, 1.0f); // 限制在0-1范围 } // 将世界坐标转换为纹理像素坐标 public Vector2Int WorldToGrid(Vector3 worldPos) { Vector2 uv WorldToUV(worldPos); return new Vector2Int( Mathf.FloorToInt(uv.x * textureWidth), Mathf.FloorToInt(uv.y * textureHeight) ); } private void OnDestroy() { if (fogMaskTexture ! null) fogMaskTexture.Release(); if (fogDataCPU ! null) Destroy(fogDataCPU); if (fogPostProcessMaterial ! null) Destroy(fogPostProcessMaterial); } }这个管理器创建了核心的数据结构并提供了坐标转换工具。注意TextureFormat.R8和RenderTextureFormat.R8的使用它们都是单通道8位格式非常节省内存和带宽。3.2 步骤二实现单位视野逻辑接下来我们创建一个FogRevealer迷雾揭示器组件挂载到需要提供视野的单位如英雄、小兵、建筑上。using UnityEngine; public class FogRevealer : MonoBehaviour { public float visionRadius 5f; public bool isActive true; private void Update() { if (!isActive || FogOfWarManager.Instance null) return; RevealFog(); } private void RevealFog() { Vector2Int centerGrid FogOfWarManager.Instance.WorldToGrid(transform.position); int radiusInGrid Mathf.CeilToInt(visionRadius / FogOfWarManager.Instance.GridSize); Texture2D fogData FogOfWarManager.Instance.FogDataCPU; // 需要将fogDataCPU改为属性或提供获取方法 // 遍历圆形区域内的所有像素 for (int dx -radiusInGrid; dx radiusInGrid; dx) { for (int dy -radiusInGrid; dy radiusInGrid; dy) { Vector2Int gridPos centerGrid new Vector2Int(dx, dy); if (IsInsideGrid(gridPos)) { float dist Vector2.Distance(centerGrid, gridPos) * FogOfWarManager.Instance.GridSize; if (dist visionRadius) { // 简单设置为白色可见。实际可以设置一个衰减值比如越远可见度越低。 fogData.SetPixel(gridPos.x, gridPos.y, Color.white); } } } } fogData.Apply(); // 应用修改 // 需要将更新后的数据同步到GPU纹理这里可以每帧或定时更新 } private bool IsInsideGrid(Vector2Int gridPos) { return gridPos.x 0 gridPos.x FogOfWarManager.Instance.TextureWidth gridPos.y 0 gridPos.y FogOfWarManager.Instance.TextureHeight; } }这个RevealFog方法每帧都会运行遍历单位周围圆形区域内的所有像素并将其设置为可见白色。但这里有两个严重问题1. 直接操作Texture2D的SetPixel在循环中调用效率极低2. 每帧Apply()和同步到GPU纹理的消耗很大。优化版本使用ComputeShader或JobSystem批量更新我们不会在这里深入ComputeShader的细节但会转向更易用的JobSystem来优化视野计算。我们需要修改FogOfWarManager让它收集所有FogRevealer的信息然后在一个Job中批量处理。首先定义描述视野器的结构体和Jobusing Unity.Collections; using Unity.Jobs; using Unity.Mathematics; public struct FogRevealerData { public float2 position; // 单位位置 (x, z) public float radius; } public struct UpdateFogJob : IJobParallelFor { [ReadOnly] public NativeArrayFogRevealerData revealers; public NativeArraybyte fogData; // 一维数组表示二维网格 public int textureWidth; public int textureHeight; public float gridSize; public float worldHalfSize; public void Execute(int index) { // 这个Job是并行处理每个网格点的所以index是网格点的一维索引 int y index / textureWidth; int x index % textureWidth; float worldX (x * gridSize) - worldHalfSize; float worldZ (y * gridSize) - worldHalfSize; byte maxVisibility 0; for (int i 0; i revealers.Length; i) { float dist math.distance(new float2(worldX, worldZ), revealers[i].position); if (dist revealers[i].radius) { maxVisibility 255; // 如果被任何一个视野器覆盖就设为完全可见 break; // 找到覆盖即跳出循环 } } fogData[index] maxVisibility; } }然后在FogOfWarManager中每帧收集视野器数据调度并运行这个Job最后将结果从NativeArraybyte拷贝到Texture2D并更新到RenderTexture。这个过程比逐像素调用SetPixel高效几个数量级。由于篇幅限制完整的Job调度和数据拷贝代码在此省略但这是生产级应用必须考虑的路径。3.3 步骤三编写后处理Shader与URP集成现在我们需要一个Shader来将迷雾遮罩纹理渲染到屏幕上。创建后处理Shader (FogPostProcess.shader)Shader Hidden/FogPostProcess { Properties { _MainTex (Texture, 2D) white {} _FogMaskTex (Fog Mask, 2D) white {} _FogColor (Fog Color, Color) (0,0,0,1) } SubShader { Cull Off ZWrite Off ZTest Always 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; }; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } sampler2D _MainTex; sampler2D _FogMaskTex; fixed4 _FogColor; fixed4 frag (v2f i) : SV_Target { // 采样屏幕颜色 fixed4 sceneColor tex2D(_MainTex, i.uv); // 采样迷雾遮罩纹理R通道 fixed fogVisibility tex2D(_FogMaskTex, i.uv).r; // 在迷雾颜色和场景颜色之间插值 fixed4 finalColor lerp(_FogColor, sceneColor, fogVisibility); // 简单处理让迷雾区域也带一点场景颜色避免纯色切割感 // finalColor lerp(_FogColor, sceneColor, fogVisibility * fogVisibility); // 使用平方让过渡更平滑 return finalColor; } ENDCG } } }这是一个非常简单的Shader。lerp函数是关键它根据fogVisibility从遮罩纹理采样得到的R通道值0到1在迷雾颜色和原始场景颜色之间进行线性插值。创建URP Renderer Feature在URP中我们需要创建一个ScriptableRendererFeature来将上述Shader插入渲染管线。在Project窗口右键Create - Rendering - URP - Renderer Feature (Custom)。命名为FogOfWarFeature。编辑其脚本核心是在AddRenderPasses方法中添加一个自定义的ScriptableRenderPass。以下是简化版的FogOfWarPass核心逻辑public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (fogMaterial null || fogMaskTexture null) return; CommandBuffer cmd CommandBufferPool.Get(Fog Of War Post Process); // 设置后处理材质所需的参数 fogMaterial.SetTexture(_FogMaskTex, fogMaskTexture); fogMaterial.SetColor(_FogColor, fogColor); // 使用Blit命令执行后处理 // source是相机当前渲染的目标destination是最终显示的目标 Blit(cmd, ref renderingData, fogMaterial, 0); // 0代表Shader中的Pass索引 context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); }将这个FogOfWarFeature添加到你的URP Asset的Renderer列表中。这样每帧渲染完成后都会执行一次迷雾后处理。3.4 步骤四实现迷雾模糊与平滑边缘现在的基础版本视野边缘是生硬的锯齿。为了获得平滑的边缘我们需要对fogMaskTexture进行模糊处理。模糊操作应该在CPU数据更新到GPU纹理之后后处理采样之前进行。我们可以在FogOfWarManager中增加一个模糊步骤。一个简单高效的方法是使用双线性过滤的降采样/升采样Downsample/Upsample也称为“金字塔模糊”。创建临时纹理创建一张长宽各一半的临时RenderTexture。降采样将全分辨率的fogMaskTexture通过Graphics.Blit缩放到临时纹理。由于纹理过滤模式是Bilinear这个过程本身就带有一次模糊。升采样再将临时纹理通过Graphics.Blit缩放回原始大小的fogMaskTexture。经过这一降一升纹理就变得平滑了。private void BlurFogTexture() { if (fogMaskTexture null) return; int blurWidth fogMaskTexture.width / 2; int blurHeight fogMaskTexture.height / 2; RenderTexture tempTex RenderTexture.GetTemporary(blurWidth, blurHeight, 0, fogMaskTexture.format); tempTex.filterMode FilterMode.Bilinear; // 第一次Blit降采样模糊 Graphics.Blit(fogMaskTexture, tempTex); // 第二次Blit升采样模糊 Graphics.Blit(tempTex, fogMaskTexture); RenderTexture.ReleaseTemporary(tempTex); }在每帧更新完fogMaskTexture后调用一次BlurFogTexture()即可。你可以通过多次迭代这个流程来获得更强烈的模糊效果但性能消耗也会增加。对于移动平台一次模糊通常就够了。至此一个基础的、带平滑边缘的圆形战争迷雾系统就搭建完成了。单位移动时它会实时揭示周围的迷雾边缘是平滑的羽化效果。4. 进阶优化与功能扩展基础系统跑起来后我们就要面对真实项目的严苛要求性能、效果和功能。下面是一些关键的进阶方向。4.1 性能优化深度策略1. 分块更新Chunked Update对于1024x1024甚至更大的地图每帧更新整张纹理是灾难性的。解决方案是分块。将地图划分为多个块例如32x32像素一块。每帧只更新那些视野器所在位置及周围受影响的数据块。这需要维护一个块状态字典记录哪些块是“脏的”需要更新并在更新Job中只处理这些脏块。更新完成后只将脏块对应的纹理区域上传到GPU可以使用Graphics.CopyTexture或Texture2D.SetPixels的带区域参数的重载。2. 多级细节LOD for Fog在远处玩家不需要看到像素级精确的迷雾边缘。我们可以为迷雾数据创建多级细节Mipmaps。在渲染时根据像素距离相机的远近使用不同级别的Mipmap进行采样。这不仅能减少带宽也能让远处的迷雾边缘自然模糊。Unity的纹理自动生成Mipmap功能可以帮我们但需要确保我们的更新逻辑能正确生成各层Mipmap的数据或者使用支持Mipmap的RenderTexture并在模糊后手动生成。3. 视野计算频率优化不是所有单位的视野都需要每帧更新。对于静止的单位如防御塔、基地其视野范围是固定的只需要在创建时计算一次除非有障碍物动态变化。对于移动缓慢的单位可以降低更新频率例如每0.2秒更新一次。这需要为FogRevealer组件增加一个更新模式UpdateMode的枚举EveryFrame,FixedInterval,Static和一个计时器。4. 使用Compute Shader进行终极加速对于追求极致性能的项目可以将整个视野计算和模糊过程放到Compute Shader中在GPU上完成。GPU拥有极高的并行计算能力非常适合这种对大量像素进行相同操作的场景。流程会变成CPU收集视野器数据 - 传入Compute Shader - GPU并行计算每个像素的可见性并写入纹理 - 在同一个Compute Shader中进行模糊处理。这完全避免了CPU到GPU的数据回传是最快的方案但实现复杂度也最高。4.2 实现“已探索”与“永远可见”区域这是策略游戏的标配功能。我们需要扩展数据层使用两个通道或两张纹理来分别表示“当前可见性”和“永久探索状态”。R通道PermanentExploredMask。一旦某个区域被任何单位探索过该点对应的值就永久设为1。G通道CurrentVisionMask。即我们之前计算的当前帧的可见性。在Shader中最终的混合逻辑需要三步如果CurrentVisionMaskG 0显示完整场景颜色。否则如果PermanentExploredMaskR 0显示“已探索”效果例如场景颜色去饱和度、变暗。否则显示全黑迷雾。同时我们还需要一个“永远可见”区域的功能比如己方主基地。这可以通过在PermanentExploredMask的基础上再叠加一个AlwaysVisibleMask可以存在另一个通道或另一张纹理来实现或者简单地在逻辑更新时将这些区域的CurrentVisionMask强制设为1。4.3 动态障碍物与视线LOS阻挡实现真实的视线阻挡是战争迷雾的“圣杯”。这里提供一个基于**预计算网格Precomputed Grid**的实用方案平衡了效果和性能。核心思路预处理阶段将场景中的静态障碍物如墙壁、山脉烘焙到一个与迷雾网格同分辨率的布尔类型网格BlockGrid中。true表示该格子被阻挡。运行时视线计算当计算一个单位的视野时不再简单地填充圆形。而是从单位中心向视野圆周上的多个方向发射“数字微分分析DDA线”。DDA遍历沿着这条线的路径在BlockGrid上一步步移动。如果遇到true的格子则停止继续向前揭示。只揭示这条线上在第一个阻挡点之前的格子。性能优化方向不需要太多通常8-16个方向每22.5度或45度一个就能得到近似圆形的视野且边缘看起来是“有棱角但合理”的。可以将方向预计算成偏移向量数组。这个方案比物理射线检测快得多因为它是在低分辨率的逻辑网格上做遍历并且可以很好地与JobSystem结合进行并行计算。4.4 与Unity地形及寻路系统的集成战争迷雾不应该只是一个视觉特效它应该影响游戏逻辑。影响寻路NavMesh在迷雾中敌方单位对玩家来说是“不存在”的。因此你的寻路系统如Unity NavMesh需要知道这一点。一种常见做法是为每个阵营维护一个“已知敌人列表”和“已知障碍物列表”。只有已知的单元才会被纳入寻路考虑。当单位进入或离开迷雾时需要动态更新这些列表并可能触发路径重算NavMeshAgent.SetDestination。地形装饰物的显示对于“已探索但不可见”区域你可能希望显示静态的地形和建筑但不显示动态的单位和可收集资源。这需要在渲染层做更精细的控制。可以为静态物体和动态物体设置不同的渲染层Layer。在后处理Shader中你可能需要额外的深度或ID纹理来判断像素属于哪种物体从而应用不同的混合规则。更工程化的做法是使用两个相机一个渲染所有“永久可见”的物体地形、静态建筑另一个渲染当前视野内的所有物体。然后将两个相机的输出在UI层或另一个后处理中根据迷雾遮罩进行合成。5. 常见问题、调试技巧与性能分析即使按照方案实现也难免会遇到各种奇怪的问题。这里记录一些我踩过的坑和解决方法。5.1 常见问题速查表问题现象可能原因排查与解决思路迷雾不显示或全屏黑色/白色1. 后处理材质未正确设置。2. 迷雾遮罩纹理全黑或全白。3. URP Renderer Feature未启用或顺序错误。1. 检查FogOfWarManager中材质和纹理的赋值在Frame Debugger中查看后处理Pass是否执行以及Shader参数是否正确传递。2. 将fogMaskTexture在UI上显示为一个RawImage检查其内容。如果全黑说明视野逻辑未运行或数据未同步如果全白说明视野逻辑可能覆盖了整个纹理。3. 在URP Asset中检查Renderer Feature列表确保你的Feature已添加并勾选。尝试调整Feature的顺序确保它在最后执行。迷雾边缘有严重锯齿马赛克1. 迷雾遮罩纹理分辨率过低。2. 纹理过滤模式为Point最近邻。3. 未进行模糊处理。1. 适当提高textureWidth/Height如512x512。注意性能权衡。2. 确保fogMaskTexture的filterMode设置为FilterMode.Bilinear。3. 确保模糊处理函数被调用且模糊迭代次数或强度足够。单位移动时迷雾更新有延迟或闪烁1. 视野计算或数据同步帧率过低。2. 双缓冲未处理好读写了同一份数据。3. Job调度有延迟。1. 使用Profiler查看RevealFog或Job的耗时。考虑优化算法或引入分块更新。2. 确保使用双缓冲CPU逻辑写入一个缓冲区完成后交换或拷贝到用于渲染的纹理。避免边读边写。3. 确保Job的依赖关系正确Complete()方法在需要结果的帧被调用。移动设备上发热严重帧率低1. 每帧全纹理更新和模糊开销大。2. 视野计算过于复杂单位太多。3. Shader复杂度高。1.必须实施分块更新。只更新变化的部分。2. 降低视野计算频率对静止单位使用静态视野减少Job中循环的视野器数量如按距离裁剪。3. 简化后处理Shader移除不必要的计算。考虑使用更低分辨率的迷雾纹理。“已探索”区域显示不正确1. 永久探索状态纹理未正确更新。2. Shader中混合逻辑有误。3. 数据精度问题如比较浮点数用等号。1. 调试时分别可视化CurrentVisionMask和PermanentExploredMask看哪个出了问题。2. 仔细检查Shader中lerp的条件和顺序。使用Frame Debugger逐步查看Shader输出。3. 使用 0.1之类的阈值进行比较而非 1.0。5.2 调试与可视化技巧纹理可视化在场景中创建一个Canvas和RawImage将fogMaskTexture赋值给它可以实时看到迷雾数据的“鸟瞰图”这是调试视野逻辑最直观的方式。使用自定义Gizmos在FogRevealer的OnDrawGizmos或OnDrawGizmosSelected中绘制其视野范围的圆形或扇形Gizmo方便在Scene视图中查看和调整参数。Profiler是朋友一定要习惯使用Unity Profiler。重点关注CPURevealFog或你的Job函数耗时。如果超过2ms就要警惕。GPU后处理PassFogOfWarPostProcess的耗时。全屏后处理本身就有开销确保你的Shader是轻量的。RenderRenderTexture的创建和更新操作如Graphics.Blit也会带来开销。Frame Debugger逐帧查看渲染指令确认你的后处理Pass是否被加入以及渲染目标、Shader参数是否正确设置。5.3 性能分析实战从10个单位到1000个单位假设你的游戏需要支持大规模军团战。10个单位基础的每帧双循环更新可能感觉不到卡顿1ms。100个单位双循环的O(N*R²)复杂度开始显现帧时间可能上升到5-10ms在低端手机上会感到明显卡顿。1000个单位基础方案直接崩溃帧时间可能超过50ms。优化路径第一层优化JobSystem将双循环改为并行Job。这能充分利用多核可能将100个单位的计算时间从10ms降到2-3ms。但对于1000个单位即使并行计算量依然巨大。第二层优化分块与脏矩形1000个单位不可能均匀分布在地图每个角落。大部分单位是扎堆的。通过分块你可能每帧只需要更新10-20个块而不是整个1024x1024的纹理。计算量骤降。第三层优化计算下采样在计算视野时可以使用比渲染纹理更低的分辨率如1/4大小。计算完成后再通过模糊和上采样得到最终纹理。这能减少4倍的像素计算量而视觉损失在可接受范围内。第四层优化静态/低频更新将单位分类。建筑、资源点等静态视野器只需在创建和销毁时更新。移动缓慢的单位如坦克可以每5帧更新一次。只有高速移动的侦察单位才需要每帧更新。终极优化Compute Shader如果上述优化仍不能满足要求例如超大规模RTS就必须上Compute Shader将计算完全卸载到GPU。这需要较强的图形编程能力但性能提升是数量级的。实现一个完整的战争迷雾系统就像搭建一个微型的图形引擎子系统。它要求你对Unity的渲染管线、并行计算、数据管理和Shader编程都有一定的理解。从基础圆形迷雾开始逐步加入平滑、多状态、视线阻挡、性能优化这个过程本身就是对游戏开发技术栈的一次深度遍历。希望这份从基础到进阶的指南能为你点亮探索“未知区域”的第一盏灯。当你看到自己操控的单位在动态的迷雾中开拓地图而敌军的动向因为迷雾变得诡谲莫测时那种亲手创造游戏核心玩法的成就感是无与伦比的。