图形引擎实战:URP自定义后处理框架及扩展
前言
游戏后处理效果是游戏开发过程中不可或缺的部分。它们通过颜色、光照、模糊等技术手段,让游戏画面更加逼真、迷人。在Unity的Built-in管线下,想要完成后处理效果,要么引进著名的后处理插件Post Processing Stack来实现此目标,要么使用OnRenderImage()配合Shader的方法进行自定义。当使用以上方法实现后,便可以对场景使用想要的后处理效果,并且自由度很高,可以随时进行修改和扩展。
而当项目转到了URP下,则会发现URP已经自带了一套相应的后处理框架Volume,不再需要去下载插件来实现目的,但随着项目的逐渐开发,一些其它方面问题却不请自来。如:
- 想要添加别的后处理效果应该怎么做?
- 怎么控制不同后处理的处理时机?
- 怎样实现一些特殊的效果?
- ......?
添加自定义的后处理
在如何添加URP没有的后处理效果这个问题上,大致有如下两种方案。
第一种就是修改URP自带Volume的源码,让添加的后处理特效融入进Volume里。首先定位到当前Unity使用的URP包体文件com.unity.render-pipelines.universal。然后对其进行修改,在Volume上面添加自己的后处理效果。这个方法能让自定义的后处理效果完美的融入URP的后处理框架。但是弊端也很明显——需要修改源码,难度较大,且破坏了包体的完整性,如果后续有升级会很麻烦。
第二种就是使用Unity提供的RenderFuture来添加自己的想要的后处理效果,这里主要参考https://www.bilibili.com/read/cv11343490,它是Unity官方提供的添加自定义后处理的案例,利用RenderFuture添加了一个后处理效果。大家可以仔细学习一下,顺便了解一下Render Future是用来做什么的。很显然这个添加后处理的方法比第一种要优雅,同时也不会对URP本身的代码进行修改,能够满足一定需求。但是添加一个后处理效果未免还是复杂了一丢丢,需要额外关注Render Future和RenderPass,而且每写一个后处理就要写一个RenderPass吗?因此不禁会想,能不能在添加后处理效果时只关注后处理面板和实现逻辑且将添加的后处理效果合并在一个RenderPass里呢?所以为了解决这个问题且方便后续管理和扩展,需要制作一个URP自定义后处理的框架 。
制作框架
为了方便使自定义后处理能够出现在Volume组件里且易于管理,这里新定义了一个类VolumeSetting,具体如下:
public abstract class VolumeSetting : VolumeComponent, IPostProcessComponent { public abstract bool IsActive(); public bool IsTileCompatible() => false; public abstract Shader GetShader(); }
IsActive()方法获取当前后处理效果是否开启;GetShader()方法获取当前自定义后处理所使用的Shader。所有的自定义后处理设置都需要继承VolumeSetting,举个例子,像这样
[VolumeComponentMenu("CYEffects/Radial Blur")] public class RadialBlur : VolumeSetting { public override bool IsActive() => BlurRadius.value != 0; //此后处理效果所需要的参数,展示在Volume后处理效果设置面板里 public RadialBlurQualityParameter QualityLevel = new RadialBlurQualityParameter { value = RadialBlurQuality.RadialBlur_8Tap_Balance }; public FloatParameter BlurRadius = new ClampedFloatParameter(0f, -1f, 1f); public FloatParameter RadialCenterX = new ClampedFloatParameter(0.5f, 0f, 1f); public FloatParameter RadialCenterY = new ClampedFloatParameter(0.5f, 0f, 1f); public override Shader GetShader() { return ShaderResouce.Instance.RadialBlurShader; } }
接下来开始考虑如何渲染它,首先创建了一个抽象类AbstractVolumeRenderer和VolumeRenderer,其中AbstractVolumeRenderer负责每个自定义后处理需要做的事情,而VolumeRenderer继承AbstractVolumeRenderer并且将自定义后处理的设置和数据传递过去。Init()方法就是根据传递过来的信息动态创建了对应的Material供后续渲染使用。具体如下所示
public abstract class AbstractVolumeRenderer { public abstract bool IsActive(); public abstract bool Init(); public abstract void Render(CommandBuffer cmd, RenderTargetIdentifier source, RenderTargetIdentifier target, ref RenderingData renderingData); public virtual void Cleanup() { } } public abstract class VolumeRenderer<T> : AbstractVolumeRenderer where T :VolumeSetting { public T settings => VolumeManager.instance.stack.GetComponent<T>(); public override bool IsActive() { bool active = settings.IsActive(); if (!active) return false; return m_BlitMaterial != null; } public Material m_BlitMaterial = null; public override bool Init() { Shader myshader = settings.GetShader(); if (myshader != null) { m_BlitMaterial = CoreUtils.CreateEngineMaterial(myshader); return true; } return false; } public override void Cleanup() { if (m_BlitMaterial != null) { CoreUtils.Destroy(m_BlitMaterial); m_BlitMaterial = null; } } }
每个自定义后处理效果只需要继承VolumeRenderer类,在Render()方法写下后处理的具体运行逻辑即可。例子如下
public class RadialBlurRenderer : VolumeRenderer<RadialBlur> { //方便FrameDebug查看的标志 private const string PROFILER_TAG = "RadialBlur"; //获取Shader内的参数 static class ShaderIDs { internal static readonly int Params = Shader.PropertyToID("_Params"); } //后处理效果处理逻辑 public override void Render(CommandBuffer cmd, RenderTargetIdentifier source, RenderTargetIdentifier target, ref RenderingData renderingData) { if (m_BlitMaterial == null) return; cmd.BeginSample(PROFILER_TAG); m_BlitMaterial.SetVector(ShaderIDs.Params, new Vector3(settings.BlurRadius.value * 0.02f, settings.RadialCenterX.value, settings.RadialCenterY.value)); cmd.Blit(source, target, m_BlitMaterial, (int)settings.QualityLevel.value); cmd.EndSample(PROFILER_TAG); } }
完成这些,就可以通过RenderFuture的形式将效果应用在项目里;创建一个新的ScriptableRenderPass,在这里声明一个AbstractVolumeRenderer的List;并在构造函数里对其进行初始化
List<AbstractVolumeRenderer> m_PostProcessingRenderers = new List<AbstractVolumeRenderer>(); public CYBeforeRPPPass() { OnInit(); } public void Setup(in RTHandle source) { m_Source = source; } void OnInit() { AddEffect(new RadialBlurRenderer()); //...... } protected void AddEffect(AbstractVolumeRenderer renderer) { m_PostProcessingRenderers.Add(renderer); renderer.Init(); }
然后在Execute()函数里进行调用,利用CommandBuffer进行渲染数据传递,大致代码如下
public override void Execute(ScriptableRenderContext context, ref RenderingDatarenderingData) { void Swap() => CoreUtils.Swap(ref m_Source, ref buff0); int count = 0; using (new ProfilingScope(cmd, m_ProfilingRenderPostProcessing)) { foreach (var renderer in m_PostProcessingRenderers) { if (renderer.IsActive()) { renderer.Render(cmd, GetSource(), GetTarget(), ref renderingData); Swap(); count++; } } if (count > 0 && count % 2 != 0) { Blit(cmd, GetSource(), GetTarget()); } } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); }
最后将创建的ScriptableRenderPass在创建的RenderFuture里进行初始化和执行,至此整个框架创建完毕。
此框架能够方便的添加想要的后处理效果。我们只需要关注后处理需要的参数和后处理的执行逻辑,不必关心其它和URP相关的事情,完美符合我们的预期!
一些扩展改进和遇到的问题
第一个就是怎么控制不同后处理的处理时机。项目里有个别效果渲染时机和正常的后处理不同,比如项目里用来模仿丁达尔效应的Sunshaft效果,它只需要使用处于Opaque队列的物体的深度,因此针对这种需要提前渲染的后处理效果,我们额外创建了一个ScriptableRenderPass,且设置它的渲染时机为渲染透明物体前,即:RenderPassEvent = AfterRenderingSkybox;然后将此后处理Sunshaft在这个ScriptableRenderPass里进行Init()、AddEffect()等初始化操作。最后把它添加进我们的RenderFuture里。而其它后处理我们将其放在一个RenderPassEvent为BeforeRenderingPostProcessing的ScriptableRenderPass里。拥有这两个不同时机的Pass我们就可以轻松完成大部分效果,满足各种需求。
//BeforeRenderingPostProcessing if (beforeRPPPassSetting.BeforeRPPPassEnable) { cyBeforeRPPPass.renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing; cyBeforeRPPPass.Setup(renderer.cameraColorTargetHandle); } //AfterRenderingSkybox if (beforeRTPassSetting.BeforeRTPassEnable) { cyBeforeRTPass.renderPassEvent = RenderPassEvent.AfterRenderingSkybox; cyBeforeRTPass.Setup(renderer.cameraColorTargetHandle); }
第二个是上述代码中为什么GetShader()并没有使用Shader.FindName的形式进行初始化,这里是考虑了Unity打包时Shader没有引用导致打包缺失的问题,所以我们通过序列化文件的形式保存了我们所需要的Shader。
[CreateAssetMenu(fileName = "NewCYPostProcessAsset", menuName = "CYPostProcessNew CYPostProcess Asset")] public class ShaderResouce : ScriptableObject { public Shader RadialBlurShader; } public override Shader GetShader() { return ShaderResouce.Instance.RadialBlurShader; }
第三个是一些其他扩展,我们针对项目扩展了两个功能,Copy Color和Copy Depth,Unity本身自带这两个功能,但是Copy Color只存在绘制透明物体之前,Copy Depeh只能选择在渲染不透明物体之后和渲染透明物体之后,因此我们利用这个Future又添加了俩个Pass,可随意控制Copy Color和Copy Depth的时机,方便特效和后期使用。
if (copyColorSetting.copyColor) { cyCopyColorPass.renderPassEvent = copyColorSetting.copyColorPassEvent; cyCopyColorPass.Setup(renderer.cameraColorTargetHandle, (int)copyColorSetting.colorDownSampling); } if (copyDepthSetting.copyDepth) { cyCopyDepthPass.renderPassEvent = copyDepthSetting.copyDepthPassEvent; cyCopyDepthPass.Setup(renderer.cameraDepthTargetHandle, (int)copyDepthSetting.depthDownSampling); }
第四个是对于Copy Color的优化,因为Copy Color目前在项目里仅一些特殊特效使用,因此在相应特效上挂载了一个计数器,用于计数当前所需使用Copy Color的特效数量,当数量大于等于1时,Copy Color的Pass才会开启。
if (EngineUtility.needColorTexCount > 0) { cmd.GetTemporaryRT(Shader.PropertyToID(colorTarget.name), Screen.width / colorDownSampling, Screen.height / colorDownSampling);//获取临时rt cmd.SetGlobalTexture(colorName, colorTarget);//设置给shader中 Blit(cmd, m_Source, colorTarget); }
第五个是延伸出来的问题,游戏中的一些技能是带有全屏特效的,在Built——in下你可以在Shader里使用GrabPass直接获取,而URP下只能利用此框架里的CopyColor,但是部分全屏特效使用的CopyColor图片是渲染不透明物体之后的图片,而全屏特效则是在不透明队列渲染,这就导致CopyColr在渲染全屏特效后才进行渲染,全屏特效效果就出错了。针对此问题,本项目是利用URP自带的RenderObjects将这类全屏特效滞后处理,使其在CopyColor的后面进行绘制。
结语
为了方便的在URP下添加后处理效果,本篇文章简单介绍了一下针对本项目的一种后处理框架实现方式,同时给出了一些遇到的问题的解决思路,并不适用于所有项目,实际应用还需要看具体情况,找出最合适的解决方法。针对本篇文章如有疑问和建议,欢迎指正和讨论,希望能帮到大家。
最后欢迎加入我们!
感兴趣的同学可以投递简历至:CYouEngine@cyou-inc.com
#我的成功项目解析##游戏引擎##技术美术#