Unity战争迷雾系统:GPU驱动的高性能实现与实战教程

📅 2026/8/4 9:56:16
Unity战争迷雾系统:GPU驱动的高性能实现与实战教程
1. 项目概述为什么我们需要一个“战争迷雾”在策略游戏、MOBA或者RTS里你肯定见过这样的场景地图上只有你单位周围的一小片区域是亮的其他地方被一片黑暗或者半透明的“迷雾”笼罩着。随着你的单位移动这片被照亮的区域也跟着移动之前探索过但当前不在视野内的区域会以一种“已探索但不可见”的状态比如变成灰色显示出来。这个系统就是“战争迷雾”。它绝不仅仅是为了增加画面的神秘感。从游戏设计的角度看战争迷雾是平衡游戏、创造策略深度和提升玩家沉浸感的核心机制。它强制玩家进行侦察让偷袭、埋伏、战术欺骗成为可能。没有它游戏就变成了双方在地图上“明牌”对打策略性会大打折扣。在Unity里实现一个战争迷雾系统听起来像是图形编程的“高阶魔法”涉及Shader、渲染纹理、后处理等让人望而生畏的名词。网上能找到的教程要么过于简单一个圆形遮罩了事要么过于复杂直接上开源方案代码几千行。很多开发者卡在中间想要一个效果专业、性能可控、且自己能完全掌控和定制的系统却不知从何下手。这篇文章我就以一个实际项目开发者的角度带你用大约30分钟的时间从零开始搭建一个专业级的战场迷雾系统。这个系统将具备实时动态更新迷雾能随着单位移动而实时变化。视野与记忆区分“当前可见”、“已探索但不可见”战争记忆和“完全未探索”区域。性能友好使用GPU进行核心计算对CPU压力极小。高度可定制你可以轻松调整迷雾颜色、视野形状圆形、锥形、衰减边缘、甚至加入动态效果如迷雾翻滚。清晰的架构代码结构清晰方便你后续扩展比如加入“真实视野”有障碍物阻挡或者多人同步逻辑。我们不会使用任何需要付费的Asset Store插件所有代码和原理都将透明呈现。只要你熟悉Unity的基本操作和C#编程就能跟上。让我们开始吧。2. 核心思路与架构设计GPU驱动的“两张纹理”在深入代码之前理解核心思路至关重要。我们的目标是用最符合GPU工作方式的手段来实现迷雾。CPU逐个单位计算然后去修改像素那效率太低了。正确的思路是将问题转化为纹理的混合与绘制。我们可以将整个游戏地图想象成一张画布。战争迷雾的状态由两张关键的“纹理”来决定视野纹理一张和地图等比例的灰度图。白色值1代表“此处当前被至少一个友方单位看见”黑色值0代表“此处完全不可见”。单位移动时我们就在这张纹理上对应的位置“画”上白色的形状比如圆形。记忆纹理另一张和地图等比例的灰度图。它记录所有曾经被探索过的地方。只要一个地方被看见过对应位置就会被“点亮”设为白色。它通常只增不减或者缓慢衰减代表了玩家的“战争记忆”。最终的迷雾效果就是综合这两张纹理的信息通过一个自定义的Shader在摄像机渲染完场景后叠加上一层带有颜色的半透明层。我们的核心架构流程如下初始化创建两张与地图区域对应的Render Texture渲染纹理作为我们的“视野纹理”和“记忆纹理”。每帧更新将“记忆纹理”作为输入复制一份到临时纹理。在临时纹理上根据所有友方单位的当前位置用Shader“画”上代表视野的白色图形。将绘制好的临时纹理同时写回“视野纹理”和“记忆纹理”记忆纹理是叠加不清除。后期渲染使用一个全屏后处理Shader读取当前屏幕像素对应的地图UV坐标去采样“视野纹理”和“记忆纹理”。根据采样结果当前可见、仅记忆可见、完全不可见决定输出最终的颜色正常场景色、混合记忆色的场景色、纯迷雾色。这个架构的巧妙之处在于最耗时的“视野绘制”工作为每个单位画圆被放在了Shader中通过GPU并行处理效率极高。CPU只负责传递单位位置等参数。注意这里我们采用了一种简化但效果很好的“软混合”方式即视野边缘是渐变的这比生硬的圆形边界看起来要自然得多也更容易在Shader中实现。3. 实战第一步创建与配置渲染纹理打开Unity创建一个新项目或使用现有项目。我们首先需要设置渲染纹理。3.1 创建FogOfWarManager脚本这是系统的总控中心。创建一个名为FogOfWarManager的C#脚本并将其挂载到一个空的GameObject上例如命名为“_FogOfWarSystem”。using UnityEngine; public class FogOfWarManager : MonoBehaviour { // 单例模式方便全局访问 public static FogOfWarManager Instance { get; private set; } // 地图的世界坐标边界 public Vector2 mapWorldSize new Vector2(100, 100); // 迷雾纹理的分辨率像素/世界单位。值越高越清晰性能开销越大。 public float texturePixelsPerUnit 2.0f; // 渲染纹理存储当前视野和记忆 private RenderTexture _fogTexture; // 当前帧视野 private RenderTexture _memoryTexture; // 历史记忆 // 用于临时绘制的纹理 private RenderTexture _tempTexture; // 用于绘制视野的材质需要一个特殊的Shader public Material fogDrawMaterial; // 用于最终屏幕叠加的材质后处理Shader public Material fogApplyMaterial; // 所有需要更新视野的单位列表 private System.Collections.Generic.ListFogRevealer _revealers new System.Collections.Generic.ListFogRevealer(); private void Awake() { if (Instance ! null Instance ! this) { Destroy(this.gameObject); return; } Instance this; InitializeRenderTextures(); } private void InitializeRenderTextures() { // 计算纹理尺寸 int texWidth Mathf.RoundToInt(mapWorldSize.x * texturePixelsPerUnit); int texHeight Mathf.RoundToInt(mapWorldSize.y * texturePixelsPerUnit); // 创建RenderTexture使用R8格式单通道灰度图以节省内存 RenderTextureDescriptor desc new RenderTextureDescriptor(texWidth, texHeight, RenderTextureFormat.R8, 0); desc.enableRandomWrite true; // 允许Shader读写 _fogTexture new RenderTexture(desc); _memoryTexture new RenderTexture(desc); _tempTexture new RenderTexture(desc); // 初始化纹理为黑色完全不可见 ClearTexture(_fogTexture, Color.black); ClearTexture(_memoryTexture, Color.black); ClearTexture(_tempTexture, Color.black); // 将初始化好的纹理传递给应用材质的Shader if (fogApplyMaterial ! null) { fogApplyMaterial.SetTexture(_FogTex, _fogTexture); fogApplyMaterial.SetTexture(_MemoryTex, _memoryTexture); fogApplyMaterial.SetVector(_MapWorldSize, mapWorldSize); } } private void ClearTexture(RenderTexture rt, Color color) { RenderTexture.active rt; GL.Clear(true, true, color); RenderTexture.active null; } // 在LateUpdate中更新迷雾确保所有单位位置已更新 private void LateUpdate() { UpdateFogTextures(); } private void UpdateFogTextures() { if (fogDrawMaterial null || _revealers.Count 0) return; // 步骤1: 将上一帧的记忆纹理复制到临时纹理作为绘制画布 Graphics.Blit(_memoryTexture, _tempTexture); // 步骤2: 为每个视野提供者Revealer设置Shader参数并绘制 foreach (var revealer in _revealers) { if (!revealer.isActiveAndEnabled) continue; // 将世界坐标转换为迷雾纹理的UV坐标 Vector2 uvPos WorldToFogUV(revealer.transform.position); fogDrawMaterial.SetVector(_RevealerPos, new Vector4(uvPos.x, uvPos.y, 0, 0)); fogDrawMaterial.SetFloat(_RevealerRadius, revealer.radius * texturePixelsPerUnit / mapWorldSize.x); // 转换为UV空间的半径 // 使用Blit进行绘制将临时纹理同时作为源和目标实现叠加绘制 Graphics.Blit(_tempTexture, _tempTexture, fogDrawMaterial); } // 步骤3: 绘制完成后临时纹理包含了当前视野历史记忆。 // 将它复制到当前视野纹理 Graphics.Blit(_tempTexture, _fogTexture); // 同时它也成为了新的记忆纹理因为历史记忆已被包含在内 Graphics.Blit(_tempTexture, _memoryTexture); } private Vector2 WorldToFogUV(Vector3 worldPos) { // 将世界坐标原点视为地图左下角 float u (worldPos.x mapWorldSize.x * 0.5f) / mapWorldSize.x; float v (worldPos.z mapWorldSize.y * 0.5f) / mapWorldSize.y; // 假设地图在XZ平面 return new Vector2(Mathf.Clamp01(u), Mathf.Clamp01(v)); } // 供FogRevealer脚本注册和反注册 public void RegisterRevealer(FogRevealer revealer) _revealers.Add(revealer); public void UnregisterRevealer(FogRevealer revealer) _revealers.Remove(revealer); private void OnDestroy() { // 释放RenderTexture资源 if (_fogTexture ! null) _fogTexture.Release(); if (_memoryTexture ! null) _memoryTexture.Release(); if (_tempTexture ! null) _tempTexture.Release(); } }3.2 创建“视野提供者”脚本任何需要揭开迷雾的单位如英雄、小兵、建筑都需要挂载这个脚本。using UnityEngine; public class FogRevealer : MonoBehaviour { public float radius 5.0f; // 视野半径 private void OnEnable() { if (FogOfWarManager.Instance ! null) FogOfWarManager.Instance.RegisterRevealer(this); } private void OnDisable() { if (FogOfWarManager.Instance ! null) FogOfWarManager.Instance.UnregisterRevealer(this); } // 可选在Scene视图中绘制Gizmo以便于调试 private void OnDrawGizmosSelected() { Gizmos.color Color.cyan; Gizmos.DrawWireSphere(transform.position, radius); } }3.3 创建绘制迷雾的Shader在Project中创建两个Shader。第一个是用于在纹理上“画圆”的Shader我们叫它FogDraw.shader。这是一个非常简单的图像效果Shader。Shader Hidden/FogDraw { Properties { _MainTex (Texture, 2D) white {} } SubShader { Tags { RenderTypeOpaque } 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; float4 _RevealerPos; // xy是中心点UV坐标 float _RevealerRadius; // 半径UV空间 fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); // 计算当前片段到视野中心的距离 float dist distance(i.uv, _RevealerPos.xy); // 计算一个平滑的衰减因子。在半径内为1在半径外为0边缘平滑过渡。 float falloff smoothstep(_RevealerRadius, _RevealerRadius * 0.8, dist); // 将衰减因子与当前纹理值取最大值实现“点亮”效果白色为1黑色为0 col.r max(col.r, 1.0 - falloff); return col; } ENDCG } } }实操要点与避坑纹理分辨率texturePixelsPerUnit是关键参数。设为1意味着1世界单位对应1像素迷雾边缘会有明显锯齿。设为2或3通常能取得效果和性能的平衡。过高的值如10会显著增加GPU带宽消耗。地图边界我们的WorldToFogUV函数假设地图中心是世界原点(0,0,0)且地图向X和Z正负方向各延伸mapWorldSize/2。如果你的地图布局不同需要修改这个坐标转换函数。RenderTexture格式使用R88位单通道格式足以存储灰度信息比默认的ARGB32节省75%的显存。确保创建描述符时设置enableRandomWrite true以便Shader能够写入。单位注册务必在FogRevealer的OnEnable和OnDisable中注册/注销而不是Start/OnDestroy因为对象池重用单位时可能频繁激活/禁用。4. 实战第二步实现屏幕后处理迷雾应用现在我们有了一张动态更新的“视野灰度图”接下来需要将它应用到整个游戏画面上。4.1 创建应用迷雾的Shader创建第二个ShaderFogApply.shader它将作为一个全屏后处理效果运行。Shader Hidden/FogApply { Properties { _MainTex (Main Texture, 2D) white {} // 摄像机渲染的场景图 _FogTex (Fog Texture, 2D) black {} // 当前视野纹理 _MemoryTex (Memory Texture, 2D) black {} // 记忆纹理 _MapWorldSize (Map World Size, Vector) (100, 100, 0, 0) _FogColor (Fog Color, Color) (0, 0, 0, 1) // 未探索区域颜色 _MemoryColor (Memory Color, Color) (0.3, 0.3, 0.3, 1) // 已探索但不可见区域颜色 _FogStrength (Fog Strength, Range(0, 1)) 0.8 // 迷雾强度 } SubShader { Tags { RenderTypeOpaque } 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; float3 worldPos : TEXCOORD1; // 传递世界坐标 }; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; // 通过深度纹理反推世界坐标更精确。这里为简化我们先使用另一种方法。 // 我们通过摄像机的视锥射线来近似计算世界坐标。 o.worldPos mul(unity_CameraToWorld, float4(v.uv * 2 - 1, 1, 1)).xyz; return o; } sampler2D _MainTex; sampler2D _FogTex; sampler2D _MemoryTex; float2 _MapWorldSize; fixed4 _FogColor; fixed4 _MemoryColor; float _FogStrength; fixed4 frag (v2f i) : SV_Target { // 1. 采样场景颜色 fixed4 sceneCol tex2D(_MainTex, i.uv); // 2. 将当前像素的世界坐标转换为迷雾纹理的UV // 假设地图在XZ平面且原点在中心 float2 fogUV; fogUV.x (i.worldPos.x / _MapWorldSize.x) 0.5; fogUV.y (i.worldPos.z / _MapWorldSize.y) 0.5; // 3. 采样视野和记忆纹理 float currentVision tex2D(_FogTex, fogUV).r; float memory tex2D(_MemoryTex, fogUV).r; // 4. 根据采样值决定最终颜色 fixed4 finalColor sceneCol; if (currentVision 0.01) // 当前完全不可见 { if (memory 0.1) // 但有记忆 { // 混合场景色和记忆色 finalColor lerp(sceneCol, _MemoryColor, _FogStrength); } else // 完全未探索 { // 直接显示迷雾色 finalColor _FogColor; } } // else: 当前可见直接显示场景色 return finalColor; } ENDCG } } }4.2 创建后处理脚本我们需要一个脚本来驱动这个全屏效果。创建一个FogOfWarPostProcess脚本并将其挂载到主摄像机上。using UnityEngine; [RequireComponent(typeof(Camera))] public class FogOfWarPostProcess : MonoBehaviour { private Camera _cam; private Material _material; void Start() { _cam GetComponentCamera(); // 从FogOfWarManager获取材质 _material FogOfWarManager.Instance?.fogApplyMaterial; if (_material null) { Debug.LogError(FogApply Material not assigned in FogOfWarManager!); enabled false; } // 启用摄像机的深度纹理模式供Shader使用虽然我们上面的简化版未使用但推荐开启 _cam.depthTextureMode DepthTextureMode.Depth; } // Unity在渲染完所有不透明物体后会自动调用此函数 void OnRenderImage(RenderTexture source, RenderTexture destination) { if (_material ! null) { // 将源图像场景和目标图像屏幕传递给Shader进行处理 Graphics.Blit(source, destination, _material); } else { // 如果材质丢失直接复制 Graphics.Blit(source, destination); } } }4.3 在Unity编辑器中完成组装在场景中创建空物体“_FogOfWarSystem”挂载FogOfWarManager脚本。在Project中右键创建两个Material材质球。将第一个材质的Shader设为Hidden/FogDraw命名为“Mat_FogDraw”。将其拖拽到FogOfWarManager的Fog Draw Material字段。将第二个材质的Shader设为Hidden/FogApply命名为“Mat_FogApply”。将其拖拽到FogOfWarManager的Fog Apply Material字段。并在这个材质Inspector面板设置你喜欢的Fog Color如黑色和Memory Color如深灰色。将FogOfWarPostProcess脚本挂载到主摄像机上。为你的玩家单位比如一个胶囊体挂载FogRevealer脚本并设置一个合适的Radius如10。调整FogOfWarManager上的Map World Size使其匹配你的游戏地图大小。运行游戏你应该能看到以你的单位为中心一个圆形的区域被照亮区域外是你设置的迷雾颜色。移动单位迷雾会随之更新。重要提示上面FogApply.shader中通过unity_CameraToWorld矩阵计算世界坐标的方法是一种近似在透视摄像机下且地形平坦时效果尚可但不精确。更健壮的做法是使用深度纹理重建世界坐标。这涉及在Shader中采样_CameraDepthTexture并进行一系列矩阵运算。由于篇幅和复杂度这里采用了简化版。如果你需要精确适配复杂地形和视角强烈建议实现深度重建版本这是专业后处理的标配。5. 性能优化与高级功能扩展基础系统搭建完成后我们来看看如何优化和增强它。5.1 性能优化要点降低纹理分辨率这是最有效的优化。在移动平台texturePixelsPerUnit设为0.5或1可能就足够了。你可以通过脚本根据设备性能动态调整。控制更新频率对于大型战略游戏成百上千个单位每帧更新迷雾开销很大。可以考虑分帧更新将_revealers列表分组每帧只更新其中一部分。降低更新频率对于移动缓慢或非核心的单位每2-3帧更新一次其视野位置。在FogOfWarManager.UpdateFogTextures()中实现这些逻辑。使用CommandBuffer对于高级用户可以使用CommandBuffer将迷雾绘制集成到渲染管线中避免额外的Blit操作但实现更复杂。纹理压缩确保RenderTexture使用合适的压缩格式如移动端用ASTC但这需要在创建时指定。5.2 实现“真实视野”视线阻挡目前我们的视野是圆形的无视地形障碍。要实现真实的视线阻挡如树林、高山挡住视野需要引入“视线检测”。思路在FogDrawShader中不是简单地画一个圆而是对从视野中心到边缘的射线进行步进采样。方法在Shader中你需要一张HeightMap高度图或WalkabilityMap可行走区域图。在绘制每个像素时计算从_RevealerPos到该像素的连线并沿这条线进行多次采样步进。如果采样点的高度值或阻挡值高于某个阈值则认为视线被阻挡停止“点亮”后续的像素。代价这会显著增加Shader的计算量从O(1)到O(n)n为步进次数需要仔细权衡性能和效果。5.3 添加动态迷雾效果让静态的迷雾看起来更生动。噪声扰动在FogApplyShader的最终输出阶段可以对fogUV进行基于时间的噪声扰动。例如fogUV float2(sin(_Time.y i.worldPos.x * 0.1) * 0.01, cos(_Time.y i.worldPos.z * 0.1) * 0.01);。这会让迷雾边缘产生轻微的流动感。多层迷雾使用两张不同缩放和移动速度的噪声图混合出更复杂的迷雾纹理模拟云雾翻滚的效果。5.4 网络同步多人游戏在多人游戏中每个玩家都有自己的战争迷雾状态。服务器需要管理“全局视野”所有玩家已探索的总和或分别同步。客户端权威每个客户端独立计算自己的迷雾基于自己收到的单位信息。这简单但可能因延迟导致不同步。服务器权威服务器计算所有玩家的“共享视野”和“个人视野”并将结果或用于计算的数据同步给客户端。这更精确但网络带宽消耗大。通常服务器只同步单位的位置和存活状态由客户端根据规则自行计算视野并在发现差异时由服务器校正关键信息。6. 常见问题排查与调试技巧在实现过程中你肯定会遇到各种问题。这里有一个快速排查清单问题现象可能原因排查步骤屏幕全黑或全白后处理材质未正确应用或Shader编译错误。1. 检查FogOfWarPostProcess脚本是否挂载到主摄像机。2. 检查Mat_FogApply材质是否赋给了Manager和PostProcess脚本。3. 在Game视图右上角将Display从Game切换到FogTex或MemoryTex需在Manager脚本中临时暴露纹理到编辑器看纹理是否有内容。迷雾不随单位移动单位坐标转换错误或视野纹理未更新。1. 在FogOfWarManager.WorldToFogUV中打印转换后的UV值看是否在[0,1]范围内且随单位移动变化。2. 在UpdateFogTextures方法中设置断点确认_revealers列表不为空且每帧都执行了绘制。迷雾边缘锯齿严重迷雾纹理分辨率太低。提高FogOfWarManager中的texturePixelsPerUnit值。同时检查FogDraw.shader中的smoothstep参数适当增加过渡区域。迷雾形状非圆形或扭曲地图世界尺寸MapWorldSize设置错误导致UV计算变形。确认MapWorldSize的X和Z值与你的实际地图大小匹配。在Shader中可视化fogUV例如将UV作为颜色输出检查其分布是否均匀。后处理导致UI也被覆盖迷雾后处理效果应用在了UI层之上。UI通常由独立的Canvas渲染且在后处理之后。确保你的UI Canvas的Render Mode不是Screen Space - Camera并且渲染摄像机不是主摄像机。或者在后处理Shader开头加入判断如果该像素属于UI则跳过迷雾计算这需要深度或模板信息。更简单的方法是使用Unity的Graphics.Blit的pass参数或URP/HDRP的Renderer Features来精确控制后处理的应用阶段。移动端性能差纹理分辨率过高或每帧Blit次数过多。1. 降低纹理分辨率。2. 确保使用R8格式纹理。3. 实现“分帧更新”逻辑减少GPU压力。4. 在Shader中使用更简单的计算避免pow,sin,cos等复杂函数在片段着色器中大量使用。调试技巧纹理可视化在FogOfWarManager中创建公共方法返回_fogTexture和_memoryTexture。然后创建一个简单的调试脚本用GUI.DrawTexture或Graphics.DrawTexture在屏幕一角显示它们实时观察迷雾纹理的变化。绘制Gizmos在FogRevealer的OnDrawGizmosSelected中绘制视野范围球体在FogOfWarManager的OnDrawGizmos中绘制地图边界框在Scene视图中直观理解空间关系。最后我个人的体会是战争迷雾系统是游戏开发中一个非常经典的“数据驱动渲染”案例。它教会你如何将游戏逻辑状态谁看见了什么转化为图形数据纹理再通过渲染管线影响最终画面。一旦你理解了这张“纹理”作为中间桥梁的核心思想很多类似的系统如动态脚印、势力范围着色、魔法效果扩散都可以用相似的架构来实现。不要满足于一个能跑的版本多尝试调整参数、修改Shader甚至重构成更高效的Compute Shader版本这个过程中对Unity渲染管线的理解会深刻得多。