本文章已于2021.9.24更新,提供一个JobSystem加速示例。

本文章已于2021.3.23更新,将用到的Shader代码转为HLSL语言,支持SRP Batcher(虽然在这里并没有什么卵用),优化模糊Blit次数。

前言

这阵子在研究战争迷雾相关的内容,在网上找了一些文章和开源库,主要有两种做法:

  • 一是直接在场景上放一张大面片,用作迷雾,这种方式适用于相机角度不会发生改变的情况,否则会有穿帮的风险,但这种方案性能比较好。
  • 二是基于屏幕空间对迷雾纹理进行采样,然后通过后处理的方式得到最终游戏画面,这种方案最为稳妥,但相应的性能会低一些。

但是网上找得到的战争迷雾方案全都是Built-In管线下进行的,那么对于基于屏幕后处理的战争迷雾,就会不可避免的涉及到OnRenderImage和Blit操作,这些操作在默认不指定相机RenderTexture的情况下会涉及对GPU中的FrameBuffer进行拷贝 + 和CPU与GPU之间的数据传递,都是比较消耗性能的操作,需要尽量减少,单一个战争迷雾可能还好,后处理特效多起来后,对于移动平台来说,本就不富裕的带宽再次雪上加霜。

而URP管线下我们可以自定义Pass并自定义渲染顺序来减少Blit操作(这方面的成熟应用可以参照 ILRuntime作者林若峰分享:次世代手游渲染怎么做 ),正好我也一直想实战一下URP,所以准备研究下URP管线下的战争迷雾。

本文包括战争迷雾从Built-In到URP的管线切换和JobSystem加速优化以及开发过程中遇到的问题以及解决方案等内容。

由于本人第一次着手渲染效果方面的开发,可能有些地方考虑欠佳甚至是错误的,恳请各位大佬不吝赐教。

本文基于:FogOfWar开源库

本文项目链接:接入URP管线的战争迷雾

正文

运行架构

既然要进行大刀阔斧的管线切换,免不了先通读一下源码,了解一下它的运行流程。

v2-338159e13a640edaa2a0095f70d5a3ab_720w

运行流程就如上图所示,我们可以看到,对于战争迷雾的可见性计算是完全独立的模块,所以可以考虑进行JobsSystem加速。

渲染流程

这部分其实是有些波折的,使用的并非是开源库作者的最终做法,原因下文会给出。

战争迷雾的渲染流程是,先对Mask Texture进行模糊处理,然后使用Shader进行屏幕空间uv采样Mask Texture获取每个像素点对应纹素,最后与RenderTexture混合得到游戏画面。

作者首先定义了一个WorldToProjector矩阵,用于将世界坐标投影到迷雾uv空间,其中centerPos是战争迷雾中心位置的世界坐标

WorldToProjector=[1xsize001centerPos.xxsize+120100001zsize1centerPos.zzsize+120001]WorldToProjector = \begin{bmatrix} \frac{1}{xsize}& 0& 0&-\frac{1 * centerPos.x}{xsize} + \frac{1}{2} \\ 0& 1&0 &0 \\ 0& 0&\frac{1}{zsize} &-\frac{1 * centerPos.z}{zsize} + \frac{1}{2} \\ 0& 0&0 &1 \end{bmatrix}

然后进入Shader部分,前半部分是我们老生常谈的从深度图重建世界坐标,internal_WorldToProjector是我们上面提到的WorldToProjector矩阵

1
2
3
4
5
6
7
8
// 先对深度纹理进行采样,再得到视角空间下的线性深度值
float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));
// 得到世界空间下的位置
float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay.xyz;
// 通过internal_CameraToProjector矩阵最终得到战争迷雾uv空间坐标
worldPos = mul(internal_WorldToProjector, worldPos);
// 使用战争迷雾uv空间坐标对迷雾纹理进行采样
fixed3 tex = tex2D(_FogTex, worldPos.xz).rgb;

大体流程就是这样,还有一个主要的问题是,屏幕空间的操作在Bulit-In管线下依赖于OnRenderImage,这个API在URP管线已经无法使用了,所以需要将其Blit部分移动至一个URP下的Render Feature。

URP接入

首先介绍一下URP里的几个概念,更多内容可参见官方URP手册

  • RenderFeature:继承自ScriptableRendererFeature,可以使用RenderFeature来插入一个Pass到指定渲染阶段
  • RenderPass:继承自ScriptableRenderPass,被RenderFeature引用,是将要插入到渲染管线的一个Pass,具体渲染逻辑可以使用Command Buffer进行高度定制
  • RenderObjects:URP自带的一个RenderFeature,并已经实现好了RenderPass,作用是指定渲染阶段对指定Layer的游戏物体统一执行一次渲染
  • CommandBufferPool:URP提供的一个实用函数集,用于快速,高效获取,释放CommandBuffer
  • RenderTextureDescriptor:一个RenderTexture的描述符结构体,其中包含创建一个RenderTexture所需要的所有信息
  • RenderTargetHandle:是一个作用类似Id的东西,使用CommandBuffer进行Blit操作的时候需要用到这个Id,内部提供了几个RenderTarget方面的字段
  • RenderTargetIdentifier:和RenderTargetHandle配合使用,定义具体的RenderTexture引用

为了接管Bulit-In管线中的后处理模块,我们需要新建一个RenderFeature和RenderPass(全部代码可以去文章开头的链接查看),下面给出RenderPass的核心代码(注意点和坑都在注释里了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
if (FogOfWarEffect.Instance == null || FogOfWarEffect.Instance.m_Map == null) return;
//先获取FOW的纹理
Texture2D fowTexture2D = FogOfWarEffect.Instance.m_Map.GetFOWTexture();
if (fowTexture2D == null) return;
CommandBuffer cmd = CommandBufferPool.Get(m_ProfilerTag);
//计算近裁剪平面四个角对应向量,并存储在一个矩阵类型的变量中,用于Shader中世界坐标重建
CaculateRay(renderingData.cameraData.camera, cmd);
cmd.SetGlobalMatrix(Internal_WorldToProjector,
FogOfWarEffect.Instance.m_Renderer.m_WorldToProjector);
cmd.GetTemporaryRT(m_FowTexture.id, fowTexture2D.width, fowTexture2D.height, 0, m_FilterMode);
//将FOW纹理Blit到RenderTexture
cmd.Blit(fowTexture2D, m_FowTexture.id, m_BlurMaterial);
//申请模糊纹理用的RT,默认滤波模式为点滤波,但请使用双线性滤波或三线性滤波,否则模糊效果将极不明显
cmd.GetTemporaryRT(m_TempColorTextureForBlurFOW.id, fowTexture2D.width / 2, fowTexture2D.height / 2, 0,
m_FilterMode);
for (int i = 0; i < m_BlurInteration; i++)
{
Blit(cmd, m_FowTexture.id, m_TempColorTextureForBlurFOW.id, m_BlurMaterial);
var tempColorTextureForExchangeBlurFOW = m_FowTexture;
m_FowTexture = m_TempColorTextureForBlurFOW;
m_TempColorTextureForBlurFOW = tempColorTextureForExchangeBlurFOW;
}
cmd.SetGlobalTexture(m_FowTexture.id, m_FowTexture.id);
//申请Blit cameraColorTexture用的RT
cmd.GetTemporaryRT(m_TempColorTextureForBlitCameraColor.id, renderingData.cameraData.camera.scaledPixelWidth,
renderingData.cameraData.camera.scaledPixelHeight);
Blit(cmd, this.source, this.m_TempColorTextureForBlitCameraColor.id, m_FogOfWarMaterial);
Blit(cmd, this.m_TempColorTextureForBlitCameraColor.id, this.source);
cmd.ReleaseTemporaryRT(m_TempColorTextureForBlitCameraColor.id);
cmd.ReleaseTemporaryRT(m_TempColorTextureForBlurFOW.id);
cmd.ReleaseTemporaryRT(m_FowTexture.id);
//执行命令缓冲区命令
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}

然后在我们创建的管线Asset里添加RenderFeature就好啦

v2-5f1518daf3c08796d141c03cb13828c8_720w

最后说一下为什么没有使用迷雾开源库里原本的做法,因为它在最后渲染的时候,有这样一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private static void CustomGraphicsBlit(RenderTexture source, RenderTexture dest, Material fxMaterial)
{
//Graphics.Blit(source, dest, fxMaterial);
//return;
//因为dest为空,所有的渲染结果都将绘制到GameWindow
RenderTexture.active = dest;

fxMaterial.SetTexture("_MainTex", source);

GL.PushMatrix();
GL.LoadOrtho();

fxMaterial.SetPass(0);

GL.Begin(GL.QUADS);

GL.MultiTexCoord2(0, 0.0f, 0.0f);
//xy平面正好糊住整个屏幕
//z值代表视锥体矩阵的第几行,重建世界坐标的时候可以直接取值
GL.Vertex3(0.0f, 0.0f, 3.0f); // BL

GL.MultiTexCoord2(0, 1.0f, 0.0f);
GL.Vertex3(1.0f, 0.0f, 2.0f); // BR

GL.MultiTexCoord2(0, 1.0f, 1.0f);
GL.Vertex3(1.0f, 1.0f, 1.0f); // TR

GL.MultiTexCoord2(0, 0.0f, 1.0f);
GL.Vertex3(0.0f, 1.0f, 0.0f); // TL

GL.End();
GL.PopMatrix();
}

百度了一下大概也能知道是什么意思,就是直接Draw了一个面片到相机的近裁剪平面,然后使用相应的shader做处理,官方文档也有相应的解释

Note that if you want to use a depth or stencil buffer that is part of thesource(Render)texture, you have to manually write an equivalent of theGraphics.Blitfunction - i.e.Graphics.SetRenderTargetwith destination color buffer and source depth buffer, setup orthographic projection (GL.LoadOrtho), setup material pass (Material.SetPass) and draw a quad (GL.Begin).

但是我在URP中尝试使用CommandBuffer.DrawMesh来替代的时候,却不行

v2-f4b8f553521c50767be934dc5ff7d7a3_720w

所以只好舍弃掉这种计算方式,改为使用CommandBuffer.Blit。

JobSystem加速

JobSystem介绍参见官方文档链接

简单介绍一下JobSystem中两个主要接口

  • IJob:将一个操作放在一个线程执行
  • IJobParallelFor:可以将一个操作划分为多个块,然后放在多个线程执行,我们要使用的就是这个完全并行化的接口,具体流程参见下图

v2-4e635706e6489d211c6b8ce7a3c0b6d6_720w

由刚开始的程序运行流程图我们可以知道,我们要使用Job加速的模块是计算地图Mask可见性区域,而每个可见性区域的计算都由一个Unit决定,所以我选择将每个Unit的视野数据作为一个Job单元数据,既然我们都选择了Job批量处理,就干脆每隔固定间隔统一更新一次所有Unit的数据

1
2
3
4
5
6
7
8
9
// 这里为了方便Profile,直接每帧计算了
public void Update()
{
if (Instance.m_FieldDatas.Count > 0)
{
Instance.m_Map.SetVisible(Instance.m_FieldDatas);
Instance.m_IsFieldDatasUpdated = true;
}
}

随后将算法替换为Job实现即可,需要注意数据的初始化和释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/// <summary>
/// 在子线程计算视野
/// </summary>
/// <param name="state">参数(视野数据)</param>
private void CalculateFOV(object state)
{
if (state == null)
return;
var dt = (List<FOWFieldData>) state;
ResetNativeArray(m_Calculater.ChangeVisiblePos, this.texHeight * this.texWidth);
ResetNativeArray(m_Calculater.Arrives, this.texHeight * this.texWidth);
m_Calculater.MapData.Clear();
m_Calculater.UnitRadiusSquare.Clear();
m_Calculater.UnitPos.Clear();
for (int i = 0; i < dt.Count; i++)
{
if (dt[i] == null)
continue;
m_Calculater.RealtimeCalculate(dt[i], this);
}
if (m_Calculater.UnitPos.Length == 0)
{
return;
}
for (int i = 0; i < this.texWidth; i++)
{
for (int j = 0; j < this.texHeight; j++)
{
m_Calculater.MapData[j * this.texWidth + i] = (this.mapData[i, j]);
}
}
FOVCalculatorJob fovCalculatorJob = new FOVCalculatorJob();
fovCalculatorJob.MapDeltaX = this.m_DeltaX;
fovCalculatorJob.MapDeltaZ = this.m_DeltaZ;
fovCalculatorJob.MapDataHeight = this.texHeight;
fovCalculatorJob.MapDataWidth = this.texWidth;
fovCalculatorJob.UnitPos = m_Calculater.UnitPos;
fovCalculatorJob.ChangeVisiblePos = m_Calculater.ChangeVisiblePos;
fovCalculatorJob.Arrives = m_Calculater.Arrives;
fovCalculatorJob.MapData = m_Calculater.MapData;
fovCalculatorJob.UnitRadiusSquare = m_Calculater.UnitRadiusSquare;
JobHandle handle = fovCalculatorJob.Schedule(dt.Count, 1);
handle.Complete();
for (int i = 0; i < fovCalculatorJob.ChangeVisiblePos.Length; i++)
{
int iIndex = i % texWidth;
int jIndex = i / texWidth;
if (fovCalculatorJob.ChangeVisiblePos[i])
{
this.m_Calculater.SetVisibleAtPosition(this, iIndex, jIndex);
}
}
m_MaskTexture.MarkAsUpdated();
}

对于FOVCalculatorJob的详细内容,因为比较多,就不贴出来了,可以前往 FOVCalculatorJob.cs 查看

最后效果如下

img

在实践JobSystem期间遇到了一些问题

也总结了JobSystem的一些重要知识点

  • NativeArray是唯一支持又读又写的,但是限制为不能多个JobBatch写入同一个下标,否则会报错,如果实在要这样,可以尝试 NativeDisableParallelForRestriction 特性,这个战争迷雾的Jobsystem分支就有使用,但是需要注意的是不能有数组越界行为,否则Unity将有可能会崩溃
  • NativeXXX系列容器实现包含了引用类型,所以不能有struct包Native容器的操作,会报错

网游中的战争迷雾

这方面其实比较好移植,我也推荐大家把战争迷雾的可见性计算放在服务器上(因为LOL就是这么做的,参见引用中的《LOL - 欢迎来到轮回绝境》),然后将结果下发到客户端

比较明显的问题有两个

  • 服务器上的多线程计算,因为服务器没有JobSystem可用,所以需要我们自己去管理多线程,原作者的Demo自带了多线程,但是上了很多锁,并不优雅
  • 数据传输,其实这一块是和战争迷雾分块优化相关联的,我们不应该每次都传输整个BitMask到客户端,这样100*100的地图就会有1kb左右的消耗,地图越大,消耗也是指数上升,具体方案可考虑四叉树分块

项目实装

v2-7f06d4dd5d47e868d6c960314af80eb3_180x120

参考

十:Unity后处理性能优化

ILRuntime作者林若峰分享:次世代手游渲染怎么做

Post Process Mobile Performance : Alternatives To Graphics.Blit , OnRenderImage ?

FogOfWar开源库

LOL - 欢迎来到轮回绝境

LOL - 战争迷雾的故事

逍遥剑客 - 游戏中的战争迷雾

Ultimate Fog of War插件

在Unity实现RTS游戏的战争迷雾

窝的舔 - 用于2D游戏光照的ShadowMap生成

在Unity中实现战争迷雾(屏幕空间采样过程)

流朔 -【Unity URP】以Render Feature实现卡通渲染中的刘海投影

[实战]Unity 基于JobSystem一步一步优化骨骼DynamicBone组件 (源码)

UnityShader——玩弄GlobalFog

Fog Of War原作者博客