Unity战争迷雾系统:GPU加速实时视野计算与渲染优化

📅 2026/8/5 1:51:21
Unity战争迷雾系统:GPU加速实时视野计算与渲染优化
1. 项目概述从“黑幕”到“洞察”在策略游戏、RTS即时战略游戏或者一些带有探索元素的RPG里你肯定见过这样的场景地图初始被一片黑暗笼罩随着你控制的单位移动黑暗被逐渐驱散露出地形和资源但一旦单位离开那片区域又重新被“迷雾”覆盖你只能看到之前探索过的静态地形而无法知晓敌方单位是否已经潜入。这就是“战争迷雾”系统。它远不止是一个简单的视觉遮罩而是游戏玩法、策略深度和沉浸感的核心支柱。它决定了玩家能获取多少信息从而直接影响其决策——是冒险突进还是保守防御。我最近在一个中型规模的RTS项目中就深度重构了这套系统。客户的需求很明确要支持数百个单位同时拥有独立、实时的视野视野范围可以是圆形、扇形甚至自定义形状视野要能动态地被地形、建筑等障碍物遮挡被探索过的区域即“已探索”状态需要以半透明迷雾显示区分于完全未知的“黑色阴影”。听起来简单但要在Unity里高效、稳定地实现尤其是在移动端保持流畅里面全是细节和“坑”。传统的简单贴图混合或者基于网格预计算的方法在动态单位和复杂地形面前要么性能吃紧要么效果僵硬。这次我们就来彻底拆解这个“战争迷雾”系统看看如何从底层视野计算到顶层的动态渲染构建一套高性能的实时解决方案。2. 核心设计思路分层解耦与数据驱动面对“实时视野计算与动态遮挡渲染”这个复合需求最忌讳的就是把所有逻辑揉在一个巨大的MonoBehaviour里。我的设计核心是分层与解耦将系统划分为三个相对独立的逻辑层数据层、计算层和渲染层。这样不仅职责清晰便于维护和优化也更容易针对不同平台如PC的高性能和移动端的省电进行调整。数据层是基石。它定义了整个迷雾状态的世界表示。我通常使用一张RenderTexture作为“迷雾纹理”其每个像素对应世界坐标的一个区域存储该区域的迷雾状态。这个状态可以用一个简单的枚举或数值表示例如0或FogState.Unknown: 完全未知纯黑阴影。1或FogState.Explored: 已探索但当前无视野半透明灰雾。2或FogState.Visible: 当前有视野完全清晰。为什么用RenderTexture而不是二维数组因为最终渲染需要它。RenderTexture可以直接作为Shader的输入省去了从数组到纹理的转换开销。我们只需要在CPU或GPU计算后更新这张纹理即可。计算层是心脏负责最耗时的“实时视野计算”。它的输入是所有拥有视野的单位的Transform信息、视野参数半径、角度以及场景中的遮挡物信息。输出则是需要更新的迷雾数据。这里的核心挑战是性能。一个朴素的实现是为每个单位遍历其视野范围内的所有迷雾纹理像素计算可见性。当单位数量上百时CPU根本无法承受。因此必须将计算转移到GPU利用其并行计算能力。我的方案是使用Compute Shader。每个单位可以分配一个线程组并行计算其视野范围内的像素可见性并原子操作更新一个全局的“视野累积缓冲区”。最后再将这个缓冲区的结果与上一帧的“已探索”状态进行混合输出到最终的迷雾纹理。渲染层是皮肤负责将计算好的迷雾纹理美观地覆盖在游戏世界上。这里通常使用一个覆盖全屏或跟随摄像头的Quad配合一个自定义的Shader。这个Shader采样迷雾纹理根据像素的迷雾状态Unknown/Explored/Visible来混合场景颜色。例如对于Unknown区域直接输出黑色对于Explored区域将场景颜色与一个灰色的雾效颜色进行混合对于Visible区域则直接输出原场景颜色。动态遮挡的效果如单位走到墙后消失也在这层通过额外的深度或遮挡纹理来实现。注意这个分层设计的关键在于数据流向清晰。计算层更新RenderTexture渲染层读取它。计算层可以以较低频率如每0.1秒运行而渲染层每帧都工作这样能在效果和性能间取得很好的平衡。3. 关键技术点一GPU加速的实时视野计算视野计算是整个系统性能的瓶颈也是最能体现方案优劣的地方。我们详细拆解一下如何用Compute Shader来实现它。3.1 数据结构准备首先我们需要在C#端准备计算所需的数据并通过ComputeBuffer传递给GPU。单位数据 (UnitDataBuffer): 这是一个结构体数组每个元素包含单位的位置Vector2或Vector3的xz分量、视野半径、视野起始角度用于扇形视野等信息。这些数据每帧从游戏逻辑中同步过来。struct UnitData { public Vector2 position; public float sightRadius; public float sightAngleStart; // 弧度 public float sightAngleRange; // 弧度 public int isActive; // 0或1 }遮挡物数据 (OccluderBuffer): 描述场景中静态障碍物的信息。为了简化我们可以用一系列线段Vector2起点和终点来表示障碍物的轮廓。更复杂的3D遮挡可以用高度场或体素来表示但这里我们先处理2D情况。这个缓冲区通常在场景加载时初始化变化不频繁。视野累积缓冲区 (FogAccumulationBuffer): 这是一张与迷雾纹理分辨率一致的RWTexture2Dfloat。初始化为0。在Compute Shader中每个线程负责计算一个或多个世界坐标点是否对某个单位可见。如果可见就向这个缓冲区对应像素的位置“累加”一个值比如1.0。所有单位计算完毕后这个缓冲区每个像素的值就代表了“有多少个单位看到了这个点”。输出迷雾纹理 (FogStateTexture): 一张RenderTexture格式通常用R8_UNorm一个8位通道就足够存储0-2的状态值。3.2 Compute Shader 核心逻辑在Compute Shader中我们启动足够多的线程组。一种高效的分配方式是每个线程处理迷雾纹理的一个像素。这样线程总数等于纹理像素数完美并行。// FogOfWar.compute #pragma kernel CSMain RWTexture2Dfloat _FogAccumulationBuffer; // 视野累积缓冲区 StructuredBufferUnitData _UnitDataBuffer; // 单位数据 StructuredBufferLineSegment _OccluderBuffer; // 遮挡线段 float2 _WorldToTextureScale; // 世界坐标到纹理UV的缩放偏移 float2 _WorldToTextureOffset; int _UnitCount; [numthreads(8, 8, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { uint2 pixelCoord id.xy; // 1. 将像素坐标转换回世界坐标 float2 worldPos PixelToWorld(pixelCoord); float visibilityAccum 0.0; // 2. 遍历所有单位计算该世界点是否对每个单位可见 for (int i 0; i _UnitCount; i) { UnitData unit _UnitDataBuffer[i]; if (unit.isActive 0) continue; // 2.1 距离检查 float2 delta worldPos - unit.position; float distSq dot(delta, delta); if (distSq unit.sightRadius * unit.sightRadius) continue; // 2.2 扇形视野角度检查如果是扇形 if (unit.sightAngleRange 6.283185) { // 非360度 float angle atan2(delta.y, delta.x); float relAngle NormalizeAngle(angle - unit.sightAngleStart); if (relAngle unit.sightAngleRange * 0.5 relAngle (6.283185 - unit.sightAngleRange * 0.5)) continue; } // 2.3 遮挡检查核心 bool isOccluded false; for (int j 0; j _OccluderCount; j) { LineSegment seg _OccluderBuffer[j]; if (RayIntersectsSegment(unit.position, worldPos, seg.start, seg.end)) { isOccluded true; break; } } if (!isOccluded) { visibilityAccum 1.0; // 对该单位可见累加 } } // 3. 将累加结果写入缓冲区 _FogAccumulationBuffer[pixelCoord] visibilityAccum; }这个Shader的核心是那个双重循环。外层遍历单位内层遍历遮挡物进行射线检测。虽然看起来复杂但由于GPU的极度并行性数万个线程同时计算实际速度非常快。RayIntersectsSegment函数需要实现高效的2D线段与射线从单位到目标点的相交检测。3.3 CPU端的调度与后处理在C#端我们每帧或每隔几帧执行以下操作void UpdateFogOfWar() { // 1. 更新单位数据缓冲区 UpdateUnitDataBuffer(); // 2. 设置Compute Shader参数 fogComputeShader.SetBuffer(kernelHandle, _UnitDataBuffer, unitDataBuffer); fogComputeShader.SetInt(_UnitCount, activeUnitCount); fogComputeShader.SetTexture(kernelHandle, _FogAccumulationBuffer, accumulationRT); // ... 设置其他参数 // 3. 调度Compute Shader int threadGroupsX Mathf.CeilToInt(fogTexture.width / 8.0f); int threadGroupsY Mathf.CeilToInt(fogTexture.height / 8.0f); fogComputeShader.Dispatch(kernelHandle, threadGroupsX, threadGroupsY, 1); // 4. 后处理将累积缓冲区转换为最终迷雾状态 // 这里可以使用另一个Compute Shader或一个简单的Blit Pass。 // 逻辑是如果accumulation 0则状态为Visible(2)。 // 否则检查该点上一帧的状态如果是Visible则变为Explored(1)否则保持Unknown(0)或Explored(1)。 BlitAccumulationToFogTexture(accumulationRT, lastFrameFogTexture, currentFogTexture); }后处理步骤是关键它实现了“探索记忆”功能。我们需要保留上一帧的迷雾纹理用来判断哪些区域是从“可见”变成了“已探索”。实操心得ComputeBuffer和RenderTexture的创建和释放一定要小心避免内存泄漏。最好在OnEnable中创建在OnDisable中释放。另外单位数量很多时可以考虑对单位进行空间划分如网格在Compute Shader中只处理视野范围内的单位进一步优化。对于移动平台要密切关注Compute Shader的兼容性和性能某些低端GPU可能支持有限需要有备用的简化CPU方案。4. 关键技术点二动态遮挡检测的优化策略上面提到的RayIntersectsSegment是性能敏感点。如果场景中有成百上千的遮挡线段每个像素-单位对都要进行全量遍历计算量会爆炸。我们必须优化。4.1 空间划分四叉树/网格化最有效的优化是减少需要检测的遮挡物数量。我们可以为所有遮挡线段建立一个空间索引例如四叉树Quadtree或简单的均匀网格Grid。均匀网格实现更简单。将整个世界划分为固定大小的网格单元。预处理时将每条线段注册到它所覆盖的所有网格单元中。当需要检测从点A到点B的射线时首先使用DDA或Bresenham算法遍历射线经过的网格单元只对这些单元内的线段进行相交检测。这能极大地减少检测次数。四叉树更适合遮挡物分布极度不均匀的场景能提供更自适应的划分但实现稍复杂。在Compute Shader中实现动态的空间划分遍历比较困难。一个折中方案是在C#端对于每个单位快速查询其视野范围一个圆形或扇形所覆盖的网格单元然后将这些单元内的线段索引列表传入Compute Shader。这样Shader中每个像素只需要遍历这个“潜在相关”的线段列表而不是全部。4.2 层级细节LOD视野计算对于远距离的像素我们不需要进行那么精确的遮挡检测。可以根据像素到单位的距离采用不同的检测策略近距离进行精确的射线-线段相交检测。中距离可以将遮挡线段简化比如合并较近的线段或者降低检测频率隔几帧检测一次。远距离甚至可以不进行遮挡检测只做距离和角度判断或者使用一个预计算的“粗略可见性贴图”。这需要在视野计算Shader中增加距离判断分支。虽然GPU分支有代价但当这个分支模式在所有线程中比较一致时比如根据像素坐标的规律性代价是可以接受的。4.3 遮挡物的表示与预处理用线段表示遮挡物虽然直观但对于复杂的凸多边形碰撞体如建筑需要先将其轮廓提取成线段集。我们可以在编辑阶段或场景加载时完成这个预处理。对于会移动或旋转的动态遮挡物如可摧毁的墙则需要每帧更新其线段数据并重新注册到空间索引中这会带来一定的CPU开销。另一种思路是使用高度图或遮挡纹理。将场景的俯视图渲染成一张纹理白色区域表示可通行无遮挡黑色区域表示不可通行完全遮挡。在视野计算时单位到目标点的连线可以在这张纹理上进行步进采样如果遇到黑色像素则认为被遮挡。这种方法将复杂的几何相交检测转化为了纹理采样可能更适合GPU但精度受纹理分辨率限制且难以处理“半高”遮挡。踩坑记录早期我尝试用Physics2D.Raycast在CPU端做遮挡检测结果帧率直接崩了。即使使用RaycastNonAlloc并限制距离在单位多的情况下也不堪重负。GPU并行计算是唯一可行的实时方案。另外线段相交检测函数一定要仔细处理边界情况比如射线起点在线段上、射线与线段共线等否则会出现视觉上的错误如单位紧贴墙壁时视野出现锯齿状缺口。5. 渲染层实现迷雾着色与场景融合计算层产出了一张精确的迷雾状态纹理FogStateTexture渲染层的工作就是把它用好看的方式呈现出来。5.1 全屏迷雾着色器我们通常使用一个在后期处理OnRenderImage或通过摄像机CommandBuffer插入的全屏Pass来渲染迷雾。// FogOfWarShader.shader Shader Hidden/FogOfWarComposite { Properties { _MainTex (Scene Texture, 2D) white {} _FogTex (Fog State Texture, 2D) black {} _UnexploredColor (Unexplored Color, Color) (0,0,0,1) _ExploredColor (Explored Color, Color) (0.3,0.3,0.3,0.6) } SubShader { Pass { CGPROGRAM #pragma vertex vert_img #pragma fragment frag sampler2D _MainTex; // 当前渲染的场景画面 sampler2D _FogTex; // 迷雾状态纹理 float4 _UnexploredColor; float4 _ExploredColor; fixed4 frag (v2f_img i) : SV_Target { // 1. 采样场景颜色 fixed4 sceneColor tex2D(_MainTex, i.uv); // 2. 采样迷雾状态 (R通道: 0Unknown, 1Explored, 2Visible) float fogState tex2D(_FogTex, i.uv).r; fixed4 finalColor sceneColor; // 3. 根据状态混合 if (fogState 0.5) { // Unknown: 完全显示为未探索颜色 finalColor _UnexploredColor; } else if (fogState 1.5) { // Explored: 将场景颜色与探索色混合 finalColor lerp(sceneColor, _ExploredColor, _ExploredColor.a); } // else Visible: 直接使用场景颜色 return finalColor; } ENDCG } } }这是一个最简单的版本。_FogTex的R通道值是我们从计算层写入的0, 1, 2。在片段着色器中我们根据这个值进行判断和混合。对于Explored状态我们使用lerp进行混合_ExploredColor.a控制了迷雾的浓度。5.2 添加平滑与过渡效果直接使用0/1/2的硬切换会导致迷雾边界出现锯齿视觉上很生硬。我们可以从两个方面优化状态纹理的平滑在计算层我们不直接写入0/1/2而是写入一个连续值如可见单位的累加值。然后在后处理或渲染Shader中使用smoothstep或简单的插值来产生平滑的边界。例如将累加值从[0, 1]映射到[0, 1]的透明度边缘部分(0.2, 0.8)进行平滑。float visibility tex2D(_FogTex, i.uv).r; // 假设是累加值 float alpha smoothstep(0.2, 0.8, visibility); // 平滑过渡 finalColor lerp(fogColor, sceneColor, alpha);迷雾边缘羽化在迷雾着色器中除了基础状态还可以对迷雾纹理进行一个简单的模糊处理比如使用一个很小的高斯模糊核让迷雾与清晰区域的过渡更加自然。但这会增加一次采样或Pass的开销需要权衡。5.3 与场景元素的交互战争迷雾不应该遮挡UI和小地图。通常小地图会直接使用迷雾纹理作为遮罩在地图上绘制出已探索和当前视野的区域。对于场景中的单位如果它们进入未探索区域我们可能需要将它们渲染为半透明或完全隐藏。这可以通过在单位的着色器中额外采样迷雾纹理并根据其世界坐标对应的迷雾状态来调整其透明度或直接clip掉来实现。注意事项迷雾渲染的采样坐标i.uv必须与计算层生成迷雾纹理时使用的世界坐标到UV的转换公式完全匹配否则会出现错位。确保你的_WorldToTextureScale和_WorldToTextureOffset参数在计算和渲染阶段保持一致。另外如果游戏场景有多个层级如地面层、飞行层可能需要多张迷雾纹理或在一个纹理中使用多个通道来存储不同层级的视野信息。6. 性能调优与内存管理实战一套特性丰富的系统必须经得起性能考验尤其是在移动端。以下是我在实际项目中总结的调优要点。6.1 计算频率与分辨率权衡迷雾纹理的分辨率是首要调节阀。分辨率越高视野边缘越精细但像素数呈平方增长Compute Shader的线程数也同步增加。对于大多数游戏一张512x512的纹理足以覆盖很大的世界区域。你可以根据摄像机最远能看到的距离来估算所需分辨率。一个经验公式纹理分辨率 世界宽度 / 期望每个像素代表的世界单位大小。例如世界宽1000单位希望每个像素代表2个单位则512x512足够覆盖1024单位。计算更新的频率是另一个杠杆。不需要每帧都更新全图的迷雾。对于策略游戏单位移动速度相对较慢可以将视野计算频率降低到每秒4-10次即每0.1-0.25秒一帧。这能大幅降低GPU压力。实现时可以使用一个累加计时器在Update中控制。private float _updateInterval 0.1f; private float _timer; void Update() { _timer Time.deltaTime; if (_timer _updateInterval) { _timer 0; UpdateFogOfWar(); // 执行昂贵的计算 } // 渲染可以每帧进行因为它只采样纹理很轻量 }6.2 基于距离的细节分级计算在Compute Shader中实现一个简单的LOD根据像素到摄像机的距离决定其计算的“优先级”或“精度”。高精度区摄像机附近全量计算包括精确的遮挡检测。中精度区进行遮挡检测但可能使用简化的遮挡物表示如合并的包围盒。低精度区远处只进行距离和角度判断忽略遮挡或者使用上一帧的结果。这可以通过在Shader中判断像素的世界坐标来实现并为不同区域分配合适的线程组或使用不同的计算函数。6.3 内存与缓冲对象管理RenderTexture使用RenderTexture.GetTemporary和ReleaseTemporary来管理临时纹理。这利用了Unity的内部缓存池能有效减少GC和创建开销。ComputeBuffer同样对于每帧变化的数据如单位数据在OnEnable中创建在OnDisable中释放。对于静态数据如大部分遮挡物可以在Awake中创建并常驻内存。对象池对于频繁创建和销毁的、代表视野提供者的组件使用对象池来管理避免不必要的内存分配和GC压力。6.4 平台差异化适配PC/主机可以开启所有特性使用高分辨率纹理和高更新频率。移动端高端可以保持特性但适当降低分辨率和更新频率。移动端低端可能需要降级方案方案A性能优先完全关闭实时遮挡计算只做基于距离的圆形/扇形视野。用预计算的“导航网格可行走区域”作为粗略的可见区域。这牺牲了真实性但性能极好。方案B效果折中将视野计算放到一个低优先级的线程中使用简化的CPU算法如射线投射网格并大幅降低更新频率和精度。在脚本中使用SystemInfo来检测设备能力并在运行时动态切换配置。void Start() { if (SystemInfo.graphicsDeviceType GraphicsDeviceType.OpenGLES3 SystemInfo.systemMemorySize 3000) { // 低端移动设备 fogTextureResolution 256; updateFrequency 0.3f; enablePreciseOcclusion false; } }7. 常见问题排查与调试技巧开发过程中你肯定会遇到各种奇怪的问题。这里列一些我踩过的坑和解决方法。7.1 视野不更新或更新错误症状单位移动了但迷雾纹理纹丝不动或者更新区域错乱。排查步骤检查数据源首先在C#端Debug.Log单位的位置和视野半径确保这些数据正确传递到了UnitDataBuffer。检查Compute Shader参数确保_WorldToTextureScale和_WorldToTextureOffset计算正确。一个常见的错误是世界坐标原点与纹理UV原点左下角的映射不对。可以写一个Debug Shader将世界坐标直接可视化为颜色输出到一张临时纹理检查映射关系。检查线程组调度计算threadGroupsX和threadGroupsY的公式是否正确确保Dispatch的线程组数量足够覆盖整个纹理。简化测试屏蔽掉遮挡检测和扇形视野逻辑只做最简单的圆形距离判断看是否工作。然后逐步加入特性定位问题模块。7.2 遮挡检测出现“漏光”或“过遮”症状单位明明没有视线路径却能看到墙后的点或者明明应该看到却被遮挡。排查步骤调试遮挡物数据在场景编辑器中可视化你传入的遮挡线段。确保它们准确地包围了碰撞体并且没有缝隙。对于复杂形状线段采样不够密会导致“漏缝”。检查射线-线段相交算法这是算法核心。编写单元测试用各种边缘情况射线起点在线段上、射线与线段平行、端点相交等来验证你的RayIntersectsSegment函数。精度问题浮点数精度可能导致判断失误。在比较距离或判断相交时使用一个很小的阈值EPSILON如1e-5。空间索引错误如果使用了网格优化检查射线遍历网格单元的算法DDA是否正确确保没有跳过某些本该检测的单元格。7.3 渲染时迷雾与场景错位症状迷雾覆盖的区域和实际游戏世界对不上像是错开了一层。原因与解决这几乎总是因为计算层和渲染层使用的世界坐标到UV的转换不一致。计算层在Compute Shader中将世界坐标worldPos转换为纹理像素坐标pixelCoord公式通常是pixelCoord (worldPos - _WorldOrigin) * _WorldToTextureScale。渲染层在迷雾着色器中给定的i.uv是屏幕空间UV。我们需要将屏幕深度纹理_CameraDepthTexture还原回世界坐标再用相同的_WorldOrigin和_WorldToTextureScale转换为迷雾纹理的UV坐标。解决方案将_WorldOrigin世界坐标系下迷雾纹理覆盖区域的左下角和_WorldToTextureScale世界单位到纹理像素的比例定义为全局的Shader属性Shader.SetGlobalVector确保计算和渲染时使用同一套参数。7.4 移动端发热严重或帧率骤降症状在手机上运行一段时间后发烫或进入复杂场景时帧率下降。排查与优化Profile!使用Unity Profiler特别是Deep Profile或移动端专用性能分析工具定位是CPU瓶颈还是GPU瓶颈。很可能是Compute Shader的GPU开销太大。降低分辨率将迷雾纹理分辨率从512x512降到256x256效果损失不大但计算量减少75%。降低频率将更新间隔从0.1秒增加到0.2秒甚至0.3秒。减少单位检查是否每帧有大量非活跃单位如死亡单位、远离摄像机的单位的数据仍在参与计算。及时从UnitDataBuffer中移除它们。检查内存确保没有每帧都new新的ComputeBuffer或RenderTexture。使用缓存和池子。7.5 调试可视化工具开发阶段构建一些可视化调试工具至关重要迷雾纹理查看器在游戏画面一角以一个小窗口实时显示_FogTex的内容。可以用不同的颜色映射状态值如红Unknown灰Explored绿Visible。单位视野Gizmos在OnDrawGizmos中为每个单位绘制其视野范围圆形或扇形和朝向线。遮挡物Gizmos绘制所有传入Compute Shader的遮挡线段。性能数据显示在屏幕左上角显示当前活跃单位数、迷雾纹理分辨率、计算耗时可以使用System.Diagnostics.Stopwatch测量Dispatch前后的时间等。这些工具能让你快速定位问题是出在数据、计算还是渲染环节。