1. 项目概述与核心价值战争迷雾对于任何一个玩过《星际争霸》、《魔兽争霸3》或是《帝国时代》的玩家来说都是一个再熟悉不过的概念。它不仅仅是地图上那片未探索的黑色区域更是游戏策略、资源争夺和战术博弈的核心驱动力。在即时战略RTS或MOBA游戏中战争迷雾决定了玩家的信息获取范围是制造悬念、平衡对抗、提升竞技深度的关键设计。然而当开发者自己动手在Unity里实现这套系统时往往会发现它远不止“画一片黑色”那么简单。你需要处理单位视野的动态更新、地形遮挡、性能开销以及如何与你的渲染管线无缝结合。网上能找到的方案五花八门有的依赖复杂的Shader Graph有的需要庞大的插件支持对于只想快速验证玩法或者开发中小型项目的团队来说学习成本和集成复杂度都太高。今天分享的这套方案是我在多个RTS原型项目中反复打磨后沉淀下来的。它的核心思想是极简与高效仅用三个纯C#脚本不依赖任何特定渲染管线Built-in、URP、HDRP均可适配就能实现一套功能完整、性能可控的战争迷雾系统。这三个脚本各司其职逻辑清晰你可以在半小时内将其集成到现有项目中并完全掌控它的每一行代码。这套系统的价值在于它的可理解性和可定制性。你不需要去破解一个黑盒插件而是能清楚地知道视野是如何计算的、纹理是如何更新的、效果是如何渲染的。无论是想修改视野形状从圆形改为扇形以适应第一人称游戏还是想增加“永久可见”的地形如高地你都能在理解原理的基础上快速实现。接下来我们就深入拆解这三个脚本是如何协同工作将“迷雾”带入你的游戏世界的。2. 系统架构与核心脚本职责解析整个战争迷雾系统可以抽象为一个经典的数据驱动渲染模型。其核心数据流是游戏世界中的“揭示者”如单位产生视野数据 - 这些数据被汇总并更新到一张全局的“迷雾纹理”中 - 这张纹理通过后期处理效果最终渲染到相机画面上遮挡未探索的区域。三个脚本正是对应了这三个核心环节。2.1 脚本一FogOfWarRevealer3D.cs - 视野的源头这个脚本需要挂载在每一个需要揭示战争迷雾的游戏对象上比如你的士兵、英雄、建筑或侦察单位。它是系统的“数据生产者”。核心逻辑与参数解析它的工作原理是在Update或LateUpdate中定期或每帧向中央管理器报告自己的位置和视野范围。通常我们会用一个圆形区域来表示单位的视野。// 伪代码逻辑示意 public class FogOfWarRevealer3D : MonoBehaviour { [SerializeField] private float _visionRange 10f; // 视野半径 [SerializeField] private LayerMask _obstacleMask; // 遮挡层如地形、建筑 private Vector3 _lastPosition; private FogOfWarWorld _fogWorld; void Update() { if (_fogWorld null) return; // 如果单位移动了或者需要强制更新时 if (Vector3.Distance(transform.position, _lastPosition) updateThreshold) { // 向FogOfWarWorld报告“我在这里我的视野范围是这么大” _fogWorld.UpdateRevealer(this, transform.position, _visionRange, _obstacleMask); _lastPosition transform.position; } } }关键设计点更新策略并非每帧都必须更新。如果单位静止不动其视野信息没有变化频繁更新是浪费性能的。通常采用“位置变化超过阈值”或“固定时间间隔”的更新策略。遮挡处理这是实现真实视野的关键。脚本通过Physics.Raycast或Physics.SphereCast向FogOfWarWorld传递一个LayerMask参数。中央管理器会利用这个信息计算射线遮挡确保墙壁后的区域不会被“看穿”。视野形状示例是圆形但你可以轻松扩展。比如对于第一人称角色你可以将视野改为一个锥形扇形只需修改向中央管理器传递的数据结构从“位置半径”变为“位置方向角度距离”。实操心得LayerMask的配置至关重要。务必确保你的地形、墙壁等障碍物所在的层被正确添加到_obstacleMask中。一个常见的坑是忘记将障碍物的碰撞体设置为触发器Trigger导致射线检测无法命中。对于复杂的多层地形可能需要更精细的层管理。2.2 脚本二FogOfWarWorld.cs - 数据中枢与计算核心这是整个系统的大脑一个单例模式Singleton的管理器。它负责收集所有FogOfWarRevealer3D的报告进行统一的视野计算并将结果更新到一张渲染纹理Render Texture中。核心工作流程初始化在Awake或Start中创建一张指定分辨率如256x256的RenderTexture。这张纹理的每个像素对应游戏世界地图上的一个区域。分辨率越高视野边缘越精细但性能开销越大。对于大多数RTS游戏512x512已经足够。注册与收集FogOfWarRevealer3D在启动时会向FogOfWarWorld实例注册自己。管理器维护一个活跃揭示者的列表。视野计算在LateUpdate中遍历所有活跃的揭示者。对于每个揭示者将其世界坐标转换为迷雾纹理的UV坐标。然后以该点为中心根据视野半径在纹理空间内确定一个需要更新的矩形区域。纹理更新这是最消耗性能的部分。我们不能每帧完全重绘整张纹理。优化方法是使用命令缓冲CommandBuffer或自定义着色器只更新发生变化区域Dirty Rect。更简单的实现是准备一张临时纹理每帧清除为“不可见”如黑色然后遍历所有揭示者将他们的视野范围“绘制”设置为“可见”如白色到临时纹理上最后将临时纹理复制到主迷雾纹理。对于遮挡在“绘制”每个像素前向该像素对应的世界位置发射一条射线如果被遮挡则该像素保持“不可见”。纹理提供计算完成后将最终包含当前帧视野信息的RenderTexture公开出来供第三个脚本FOWImageEffect.cs使用。// 简化版更新逻辑示意 public class FogOfWarWorld : MonoBehaviour { public static FogOfWarWorld Instance; public RenderTexture fogTexture; // 最终输出的迷雾纹理 private ListFogOfWarRevealer3D _revealers new ListFogOfWarRevealer3D(); private Texture2D _bufferTexture; // 用于计算的缓冲纹理 void LateUpdate() { ClearBufferToBlack(); // 缓冲清空为不可见 foreach (var revealer in _revealers) { DrawVisionToBuffer(revealer); // 将每个单位的视野“画”到缓冲上 } Graphics.Blit(_bufferTexture, fogTexture); // 将缓冲复制到RenderTexture } void DrawVisionToBuffer(FogOfWarRevealer3D revealer) { Vector2 texCoord WorldToTextureCoord(revealer.Position); int radiusInPixels (int)(revealer.VisionRange / worldUnitPerPixel); for (int y -radiusInPixels; y radiusInPixels; y) { for (int x -radiusInPixels; x radiusInPixels; x) { if (x*x y*y radiusInPixels*radiusInPixels) continue; // 圆形判断 Vector2 currentPixel texCoord new Vector2(x, y); Vector3 worldPos TextureToWorldCoord(currentPixel); if (!Physics.Linecast(revealer.Position, worldPos, revealer.ObstacleMask)) { _bufferTexture.SetPixel((int)currentPixel.x, (int)currentPixel.y, Color.white); } } } _bufferTexture.Apply(); } }注意事项上述双循环遍历像素的代码在CPU端执行如果揭示者很多或纹理分辨率高会成为性能瓶颈。生产环境强烈建议将这部分计算转移到Compute Shader或使用Shader在GPU上并行处理这是实现高性能战争迷雾的关键优化步骤。CPU方案仅适用于原型阶段或极少数单位的情况。2.3 脚本三FOWImageEffect.cs - 视觉效果的呈现者这个脚本通常作为一个全屏后处理效果Image Effect附加在主相机上。它的任务很简单拿到FogOfWarWorld生成的那张RenderTexture然后通过一个自定义的Shader将其融合到最终的场景渲染画面中。核心实现步骤创建材质在Start中动态创建一个使用自定义Shader的材质球。实现OnRenderImage这是Unity后处理的标准接口。在该方法中调用Graphics.Blit(source, destination, material)。其中source是相机当前的渲染纹理destination是输出目标material是我们的迷雾材质。Shader逻辑自定义Shader是整个视觉效果的灵魂。它通常是一个简单的片段着色器Fragment Shader。输入主纹理场景颜色、迷雾纹理Fog Texture。逻辑对于屏幕上的每一个像素即片段根据其在迷雾纹理中对应位置的值例如白色代表可见黑色代表不可见来决定最终输出。基础效果如果迷雾纹理对应点为白色可见则直接输出场景颜色如果是黑色不可见则输出黑色或一个固定的迷雾颜色如深灰色。高级效果可以引入渐变。例如迷雾纹理的值从0黑到1白变化在边缘区域值在0.2到0.8之间进行平滑混合让迷雾边缘看起来柔和自然而不是生硬的锯齿。还可以根据值来混合不同的迷雾颜色实现“已探索但当前不可见”的灰色区域效果。// 简化Shader代码示意Unity ShaderLab sampler2D _MainTex; // 场景颜色 sampler2D _FogTex; // 战争迷雾纹理 float4 _FogColor; // 迷雾颜色 fixed4 frag (v2f i) : SV_Target { fixed4 sceneColor tex2D(_MainTex, i.uv); fixed fogValue tex2D(_FogTex, i.uv).r; // 假设迷雾纹理是单通道的 // 简单阈值混合 if (fogValue 0.5) return sceneColor; // 完全可见 else if (fogValue 0.2) return lerp(_FogColor, sceneColor, (fogValue - 0.2) * (1.0/(0.5-0.2))); // 边缘渐变 else return _FogColor; // 完全不可见 }与渲染管线适配Built-in RP直接使用OnRenderImage接口。URP需要创建ScriptableRendererFeature和ScriptableRenderPass在渲染管线的合适阶段通常在渲染不透明物体之后插入全屏绘制命令。HDRP同样通过自定义FullScreen Pass来实现。实操心得后处理效果对性能有影响尤其是低端移动设备。确保你的迷雾Shader尽可能简单高效。如果游戏是俯视角且UI占比较大可以考虑只对3D场景部分应用战争迷雾UI层叠加在上方这能减少像素填充率开销。可以通过设置相机的清除标志和渲染层Culling Mask来部分实现。3. 关键实现细节与性能优化实战理解了三个脚本的职责我们来看看如何把它们串起来并解决实际开发中必然会遇到的性能与效果问题。3.1 坐标转换连接世界与纹理的桥梁FogOfWarWorld中最关键的函数之一就是WorldToTextureCoord和其反函数TextureToWorldCoord。这决定了游戏世界中的一个点对应到迷雾纹理上的哪个像素。实现方案你需要定义游戏世界的边界worldBounds这是一个包围整个可探索地图的矩形或立方体区域。public Bounds worldBounds new Bounds(new Vector3(0,0,0), new Vector3(200, 0, 200)); // 假设地图是200x200 public int textureResolution 512; Vector2 WorldToTextureCoord(Vector3 worldPos) { // 将世界坐标归一化到边界框的[0,1]范围 Vector3 relativePos worldBounds.min - worldPos; // 注意顺序计算相对位置 float x Mathf.InverseLerp(worldBounds.min.x, worldBounds.max.x, worldPos.x); float z Mathf.InverseLerp(worldBounds.min.z, worldBounds.max.z, worldPos.z); // 通常用XZ平面 // 将归一化坐标映射到纹理像素坐标 return new Vector2(x * textureResolution, z * textureResolution); }注意事项worldBounds的设定必须准确覆盖所有需要战争迷雾的游戏区域。如果单位移动到了边界外转换会出错。建议在编辑器模式下绘制Gizmos来可视化这个边界便于调试。3.2 性能瓶颈分析与优化策略战争迷雾系统在单位众多时极易成为性能杀手。主要瓶颈在FogOfWarWorld的视野计算环节。1. CPU端优化分帧更新不要在同一帧更新所有揭示者。可以将揭示者列表分组每帧只更新其中一部分例如每帧更新1/3的单位。虽然这会引入1-2帧的延迟但在大多数RTS游戏中是可以接受的且能大幅平滑CPU压力。空间划分使用四叉树Quadtree或网格Grid来管理揭示者。当更新一个单位的视野时只计算其所在及相邻格子内的潜在受影响像素避免全纹理遍历。简化遮挡检测对于远距离或大量单位的射线检测Linecast开销巨大。可以考虑使用预计算的“遮挡高度图”或简化为2D平面的检测。对于固定地形可以预先烘焙静态遮挡信息。2. GPU端优化推荐将视野计算从CPU转移到GPU是质的飞跃。核心思路是FogOfWarWorld不再进行像素级循环计算。它每帧准备一个CommandBuffer。对于每个揭示者在CommandBuffer中添加一个“绘制”命令指示GPU在迷雾纹理作为RenderTarget上绘制一个代表其视野的图形如一个圆形。这个“绘制”操作由一个特殊的Shader完成该Shader在片段着色器中执行世界空间下的射线遮挡检测。所有揭示者的绘制命令提交后由GPU并行处理效率极高。// 伪代码示意GPU更新 void UpdateFogOnGPU() { commandBuffer.Clear(); commandBuffer.SetRenderTarget(fogTexture); foreach (var revealer in _revealers) { // 设置Shader属性揭示者位置、视野半径、遮挡层信息等 materialForFogUpdate.SetVector(_RevealerPos, revealer.Position); materialForFogUpdate.SetFloat(_VisionRange, revealer.VisionRange); // 绘制一个覆盖视野可能区域的网格如一个Quad commandBuffer.DrawMesh(quadMesh, Matrix4x4.TRS(revealer.Position, ...), materialForFogUpdate); } Graphics.ExecuteCommandBuffer(commandBuffer); }3. 纹理分辨率与更新频率的权衡纹理分辨率256x256适用于小地图或风格化游戏512x512是平衡选择1024x1024及以上适用于大型精细地图但内存和带宽占用翻倍增长。可以尝试使用半精度RenderTextureFormat.ARGBHalf纹理。更新频率如果单位移动缓慢可以降低FogOfWarRevealer3D的更新频率如每0.1秒检查一次位置变化。对于静态建筑其视野区域一旦计算完成就可以缓存起来除非有地形破坏等事件发生否则无需更新。3.3 视觉效果的进阶处理基础的黑白迷雾略显生硬。我们可以通过Shader实现更丰富的视觉效果。1. 迷雾边缘柔化不要在Shader中使用硬阈值如fogValue 0.5。使用smoothstep函数可以实现自然的平滑过渡。float visibility smoothstep(_FogStart, _FogEnd, fogValue); fixed4 finalColor lerp(_FogColor, sceneColor, visibility);_FogStart和_FogEnd定义了从完全迷雾到完全可见的过渡区间。2. 已探索区域与未探索区域这是RTS游戏的标配。我们需要两张纹理一张CurrentFog表示当前帧的实时视野另一张ExploredMap表示历史上所有探索过的区域只增不减。ExploredMap的更新每当CurrentFog有新的“可见”区域时就将其“刻录”到ExploredMap中ExploredMap max(ExploredMap, CurrentFog)。Shader混合最终一个区域可能有三种状态CurrentFog可见 - 显示场景原色。CurrentFog不可见但ExploredMap可见 - 显示“已探索”状态如场景颜色去饱和度、变暗。两者都不可见 - 显示黑色迷雾。实现上可以将ExploredMap作为一个通道如G通道存储在同一张纹理中或者在Shader中采样两张纹理。3. 迷雾动画让静态迷雾看起来有流动感可以增加游戏的氛围。在Shader中对迷雾纹理的采样坐标加上基于时间的轻微偏移或者混合一张噪声纹理。float2 animatedUV i.uv _Time.y * _FogFlowSpeed * float2(0.1, 0.05); fixed fogValue tex2D(_FogTex, animatedUV).r;4. 集成步骤与常见问题排查4.1 快速集成到新项目的步骤创建核心管理器在场景中创建一个空物体命名为“FogOfWarManager”挂载FogOfWarWorld.cs脚本。配置好worldBounds和textureResolution。准备后处理相机在主相机上挂载FOWImageEffect.cs脚本。将第1步中FogOfWarWorld实例生成的fogTexture拖拽到该脚本的对应参数上。制作迷雾Shader在Unity中创建新的Unlit Shader编写类似上文所述的片段着色器逻辑。将其赋给一个材质并将该材质赋给FOWImageEffect.cs脚本。添加揭示者给需要视野的单位如玩家控制的角色挂载FogOfWarRevealer3D.cs脚本。设置合适的_visionRange并配置_obstacleMask以包含地形层。配置遮挡层确保你的地形、墙壁等障碍物有碰撞体Collider并且其所在图层被包含在_obstacleMask中。运行测试运行游戏控制单位移动观察黑色迷雾是否随着单位移动而消退。4.2 常见问题与解决方案速查表问题现象可能原因排查与解决方案完全看不到迷雾场景全亮1.FOWImageEffect脚本未生效或材质丢失。2. 迷雾Shader输出逻辑错误始终返回场景色。3.FogOfWarWorld的纹理输出全白。1. 检查相机上的FOWImageEffect是否启用材质引用是否有效。2. 在Shader中硬编码返回一个测试颜色如红色看屏幕是否变红以确认Shader执行。3. 在FogOfWarWorld中将fogTexture在Inspector中显示为RawImage检查其内容是否为全白。迷雾全黑什么都看不见1. 没有激活的FogOfWarRevealer3D。2. 揭示者未成功注册到FogOfWarWorld。3. 坐标转换错误揭示者的视野被画到纹理边界外。1. 确认场景中存在挂载了FogOfWarRevealer3D且启用的GameObject。2. 在FogOfWarWorld的Update中打印_revealers.Count检查数量。3. 调试WorldToTextureCoord函数确保单位坐标能正确转换到[0, resolution]范围内。视野形状不对如方形在CPU计算循环中圆形判断逻辑错误或遗漏。检查DrawVisionToBuffer方法中的距离判断if (x*x y*y radiusInPixels*radiusInPixels) continue;是否正确。单位能“看穿”墙壁1. 障碍物图层未正确设置到_obstacleMask。2. 障碍物碰撞体是触发器Is Trigger。3. 射线检测Linecast的起点/终点坐标有误如忽略了单位高度。1. 在编辑器中双击_obstacleMask字段确认图层已勾选。2. 确保用于遮挡的碰撞体Is Trigger为false。3. 在代码中可视化绘制射线Debug.DrawLine检查射线路径是否正确。考虑使用Physics.SphereCast来提供一定的视线容差。游戏明显卡顿1. 单位数量过多CPU循环计算过载。2. 迷雾纹理分辨率过高。3. 每帧所有揭示者都在更新。1. 使用Profiler查看FogOfWarWorld.LateUpdate的耗时。如果过高必须实施优化启用分帧更新或迁移到GPU计算。2. 尝试将纹理分辨率从512降至256。3. 为FogOfWarRevealer3D添加位置变化阈值静止单位不更新。迷雾边缘有锯齿像素感迷雾纹理分辨率过低或者Shader中没有进行柔化处理。1. 适当提高textureResolution。2. 在Shader中使用smoothstep或纹理双线性过滤确保导入设置中迷雾纹理的Filter Mode不是Point。在URP/HDRP中后处理不生效OnRenderImage在可编程渲染管线中无效。必须为URP创建ScriptableRendererFeature为HDRP创建FullScreen Pass。这是集成到SRP中最主要的代码适配工作。4.3 调试技巧可视化调试在FogOfWarWorld中编写OnDrawGizmos方法绘制出worldBounds的范围以及每个揭示者的视野范围Gizmos.DrawWireSphere在Scene视图里一目了然。纹理预览在Unity编辑器中创建一个UI RawImage将其Texture指向FogOfWarWorld.Instance.fogTexture。这样你可以在游戏运行时直接在Game视图里看到实时的迷雾纹理数据是黑是白一清二楚。性能剖析务必使用Unity Profiler。重点关注LateUpdate、SetPixel如果使用CPU方案和Graphics.Blit/CommandBuffer的耗时。这能帮你精准定位是计算、渲染还是拷贝环节出了问题。最后这套三脚本系统是一个强大的起点但它不是终点。根据你的项目需求你可以在此基础上扩展出更多功能比如为不同单位设置不同的视野高度空中单位无视地形遮挡、添加动态变化的迷雾如被法术驱散、或者将迷雾数据用于AI决策AI只能对可见区域内的单位做出反应。理解了这个数据流和渲染的基本原理这些扩展都将变得有迹可循。