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_UNORM),R通道存储“永久探索状态”(已探索过即为1),G通道存储“当前可见状态”。在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更新到fogMaskTexture(RenderTexture)中。这里可能还需要一个模糊处理(如高斯模糊),让迷雾边缘变得平滑,而不是生硬的锯齿。 - 编写后处理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节点,纹理输入为fogMaskTexture,UV使用屏幕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中批量处理。
首先,定义描述视野器的结构体和Job:
using 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 NativeArray<FogRevealerData> revealers; public NativeArray<byte> 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,最后将结果从NativeArray<byte>拷贝到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中,最终的混合逻辑需要三步:
- 如果
CurrentVisionMask(G)> 0,显示完整场景颜色。 - 否则,如果
PermanentExploredMask(R)> 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。重点关注:
- CPU:
RevealFog或你的Job函数耗时。如果超过2ms就要警惕。 - GPU:后处理Pass(
FogOfWarPostProcess)的耗时。全屏后处理本身就有开销,确保你的Shader是轻量的。 - Render:
RenderTexture的创建和更新操作(如Graphics.Blit)也会带来开销。
- CPU:
- 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编程都有一定的理解。从基础圆形迷雾开始,逐步加入平滑、多状态、视线阻挡、性能优化,这个过程本身就是对游戏开发技术栈的一次深度遍历。希望这份从基础到进阶的指南,能为你点亮探索“未知区域”的第一盏灯。当你看到自己操控的单位在动态的迷雾中开拓地图,而敌军的动向因为迷雾变得诡谲莫测时,那种亲手创造游戏核心玩法的成就感,是无与伦比的。