Unity战争迷雾系统:三脚本极简实现与GPU优化方案
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 List<FogOfWarRevealer3D> _revealers = new List<FogOfWarRevealer3D>(); 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只能对可见区域内的单位做出反应)。理解了这个数据流和渲染的基本原理,这些扩展都将变得有迹可循。