Visual Effect Graph魔改录

  欢迎参与讨论,转载请注明出处。

前言

  前文提到关于粒子想实现一些东西,本篇便来还愿了。Demo使用的粒子系统并非传统的Particle System,而是基于GPU的Visual Effect Graph,下文简称VEG。
  VEG的问题与近年来Unity新出的模块一样:有些功能就做了个壳没下文了,我需要的光照探针功能便是如此。尽管VEG在编辑器做了支持,但实际上功能是没实现的:
0
  所幸Unity近年来的模块有个好处:开源,通过Package Manager下载的模块其本身已经是开源可修改的,若是对其进行一番研究搞不好就能自己实现想要的功能,不必再苦等官方?答案是没错的,我如愿以偿为VEG增加了三个功能,并将之开源了。

研究

1
  根据上图可以发现,VEG特效的构成实际上就是Compute Shader + 一般Shader,它们是由VEG编辑器生成的,双击可查看生成后的代码。由此可见,VEG实际上与Shader Graph差不多,都是通过编辑器进行创作,最后生成相应的代码
  由于先前的经验,这类源码的入手点自然是找到编辑器定义属性的地方。比如设置Shader效果的Output部分,顺藤摸瓜便很快找到了:
2
3
  以此类推,便找到了生成Shader的相关处:
4
5
6
  如此路线便打通了,以上得到了两个重要的信息:每个不同的Output类型(Quad、Cube、Mesh……)都会有对应的Shader模板,表示我们加料时也要考虑到多种类型的情况。其次是区分了LegacyUniversal两个文件夹,可见分别对应Built-in与URP管线,毕竟他们使用的Shader库并不相同。如此VEG能在老管线使用,并且在HDRP有新功能就能理解了。

接受阴影

  目前对于VEG最迫切需要的功能便是接受阴影了,无论实时阴影还是光照探针,VEG目前都是没有的。好在按照上文的路线通读一番后,发现追加接受阴影还是蛮容易的。
  顺着上文继续走下去,看到了主Pass下有个名为VFXApplyColor的插入片段在各类型的主Pass都有用到,可见是通用的着色过程。那么在这里加入阴影着色正好:
7
8
9
  通过搜索其他函数的出处找到了Shaders/RenderPipeline/Universal/VFXCommon.hlsl,到了这里便是熟悉的Shader编写环境了,Include的文件都是URP那套,写就完事了:

1
2
3
4
5
6
7
8
float4 VFXApplyShadow(float4 color, float3 posWS) {
float4 shadowCoord = TransformWorldToShadowCoord(posWS);
Light mainLight = GetMainLight(shadowCoord);
color.rgb *= mainLight.color * mainLight.distanceAttenuation * mainLight.shadowAttenuation;
return color;
}

  当然这函数还不能直接用,根据观察其他函数还会在Shaders/VFXCommonOutput.hlsl针对VEG的环境做一层封装然后写到VFXApplyColor里即可:

1
2
3
4
5
6
7
8
9
10
11
12
float4 VFXApplyShadow(float4 color,VFX_VARYING_PS_INPUTS i)
{
#if USE_RECEIVE_SHADOWS
#if defined(VFX_VARYING_POSWS)
return VFXApplyShadow(color, i.VFX_VARYING_POSWS);
#else
return VFXApplyShadow(color, (float3)0); //Some pipeline (LWRP) doesn't require WorldPos
#endif
#else
return color;
#endif
}

10
  当然还要提供阴影相关的multi_compile,搜索代码得知加在VFXPassForwardAdditionalPragma片段,然后就可以看效果啦:
11
12

光照探针

  接受实时阴影算是完成了,但还要考虑到烘焙阴影的情况,于是对于光照探针的支持也要考虑到。VEG关于光照探针的外围支持已经完备(根据光照设置、探针设置开启相关KEYWORD),欠缺的只是Shader相关的部分。
  在不考虑GI,按照我在模型渲染一样的做法的前提下,只需要在VFXApplyShadow加点料即可:

1
2
3
4
5
6
7
8
9
10
11
12
float4 VFXApplyShadow(float4 color, float3 posWS) {
float4 shadowCoord = TransformWorldToShadowCoord(posWS);
Light mainLight = GetMainLight(shadowCoord);
#if defined(_MIXED_LIGHTING_SUBTRACTIVE)
mainLight.distanceAttenuation = lerp(GetMainLightShadowStrength(), 1, saturate(mainLight.distanceAttenuation));
#endif
color.rgb *= mainLight.color * mainLight.distanceAttenuation * mainLight.shadowAttenuation;
return color;
}

  与之前的做法一样,distanceAttenuation在光照探针下会变成光照计算的着色值,将之锁定在阴影强度-1之间即可。然后烘焙阴影,设置特效组件开启光照探针即可看到效果了:
13
  比较遗憾的是,光照探针的计算是以GameObject为准的,而非以每个粒子为准,这也是没办法的事,只能尽量避免露馅了。
  关于阴影还剩最后一个点没有做:在编辑器的开关设置,这个模仿其他属性添加变量,并在VFXParticleOutput.csadditionalDefines变量里添加相关KEYWORD,最后在Shader里做判定即可:
14
15

水面问题

  目前粒子特效在水面上显示会出现很明显的层次错误:
16
  火焰实际上并没有进入水里,但是看着却变蓝了。这是因为水面的渲染时机在所有对象之后,并使用CameraColorTexture进行显示。而此时火焰已在Texture里了,于是与水重叠的部分便被水渲染处理了。
  基于这个问题可以很迅速的想到解法:利用RenderFeature的RenderObjects可以新建渲染批次,并将粒子主Pass的LightMode改为新的批次即可:
17
18
  试了下效果,问题的确解决了,但是……
19
  但是水里的火焰消失了,这也是当然的,毕竟在水面渲染之前,火焰还没渲染呢。进一步思考后想到了个绝妙的方案:为粒子新增一个与主Pass一模一样的Pass,也就是目前的ParticlePost,保留原本的主Pass,将LightMode还原。当然只是如此的话会出现一个粒子渲染两次重叠起来的情况,而我们可以让ParticlePost只在与水面重叠时显示,这样便可解决重叠问题了。
  为此我们要用上模板测试,让水面写入特定的模板值,然后在ParticlePost做判定(假设水面写入值为2):
20
  限于篇幅,关于添加Pass的做法还请自行查阅源码。看看效果吧:
21
  很棒很棒,这下算是解决粒子与水面的问题了。尽管在水面时事实上是有重叠的,看着效果还行就凑合吧。由此延伸可以说是半透明对象与水面的一种解决方案了。
  最后是编辑器相关,只能写死数值显然是不好的,这里我使用了VEG提供的定义代码段功能,在VFXParticleOutput.csadditionalReplacements变量添加,并在模板里调用即可:
22
23

后记

  VEG较之传统粒子最大的优势便是运算放在GPU以及开放源码可供修改了吧,可惜必须在支持Compute Shader的设备上才能运作。这一点注定它在手游里很难用得上了,只能期待老手机早日淘汰了……