前言

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

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

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

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

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

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

本文基于:FogOfWar开源库

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

正文

运行架构

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

FOW开源库架构

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

渲染流程

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

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

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

WorldToProjector=[1xsize001xsizecenterPos.x+12001zsize1zsizecenterPos.z+1200000001]WorldToProjector = \begin{bmatrix} \frac{1}{xsize}& 0& 0&-\frac{1}{xsize * centerPos.x} + \frac{1}{2} \\ 0& 0&\frac{1}{zsize} &-\frac{1}{zsize * centerPos.z} + \frac{1}{2} \\ 0& 0&0 &0 \\ 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.xy).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所需要的所有信息
  • RenderTargetHandler:是一个作用类似Id的东西,使用CommandBuffer进行Blit操作的时候需要用到这个Id,内部提供了几个RenderTarget方面的字段
  • RenderTargetIdentifier:和RenderTargetHandler配合使用,定义具体的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
38
39
40
41
42
43
44
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);
Blit(cmd, m_TempColorTextureForBlurFOW.id, m_FowTexture.id);
}
cmd.SetGlobalTexture(m_FowTexture.id, m_FowTexture.id);

//申请Blit cameraColorTexture用的RT
cmd.GetTemporaryRT(m_TempColorTextureForBlitCameraColor.id, renderingData.cameraData.camera.scaledPixelWidth,
renderingData.cameraData.camera.scaledPixelHeight);

//将结果写回cameraColorTexture
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就好啦

image-20210205121623473

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

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来替代的时候,却不行

image-20210204165809116

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

JobSystem加速(未完成)

由于水平有限,此部分并未完成

JobSystem介绍参见官方文档链接

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

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

A ParallelFor job dividing batches across cores

由刚开始的程序运行流程图我们可以知道,我们要使用Job加速的模块是计算地图Mask可见性区域,而每个可见性区域的计算都由一个Unit决定,所以我选择将每个Unit的视野数据作为一个Job单元数据,思路想好了,开始干活吧

。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。

好吧,最后我干了个寂寞,兴冲冲写完了一个长达300行的Job后

image-20210205183411167

还立了个Flag

image-20210205183739361

初始化好数据准备运行的时候,果然遇到了一大堆的Bug

有读写竞争的

https://forum.unity.com/threads/how-to-write-to-another-index-with-the-jobsystem.519981/

有Burst不支持引用类型的(居然是内部类型不支持,真有你的啊)

https://forum.unity.com/threads/nativequeue-and-burst.799116/

甚至不支持迭代的HashMap都来了

https://forum.unity.com/threads/nativehashmap.529803/

这些其实都有的解决办法,但是写完之后代码已经成了怪物了(应该是我并行思想不够成熟,数据划分的不够好),而且还有一些莫名其妙的小Bug,所以这一块就暂时不分享了,等日后技术更加精进,再来更新。

网游中的战争迷雾

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

比较明显的问题有两个

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

项目实装

image-20210204174722550

参考

十: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原作者博客