文章目录
前言
URP(通用渲染管线)是Unity为了提升渲染管线的可编辑性和灵活性而推出的一个渲染解决方案。它基于SRP(可编程渲染管线)技术,允许开发者通过C#脚本来定义和修改渲染流程。
从两者的关系上来说,URP是一种SRP,是在众多SRP中使用的最多的一种渲染管线,其包含有大量的管线和Shader代码,为开发者提供了多平台支持、可扩展性和模块化以及各种优化技术。尽管在学习中,我们会从0开始构建一个SRP项目,并通过熟悉各环节来逐步学习管线的各个细节;但是在实际项目中,从0开始构建一个SRP管线需要巨大的成本和工作量,并且存在风险。因此,如果中小型项目中存在定制管线的需求,我们通常认为本地化URP并进行修改会是更高效的一种方式;此外,URP作为成熟的商业化管线,阅读它的源码也有助于印证、提高我们的理论知识,本地化URP使得我们可以使用各种工具和编辑器来对源码进行可视化阅读等操作,有助于我们的进一步提升。
一.安装URP包
URP作为一种成熟的管线,早已作为Package发布,直接通过包管理器(Package Manager)进行安装即可。
由内置管线升级URP管线的时候,可能带来一系列的问题,如部分使用BuildIn Shader的材质变成BUG紫之类的问题,部分可以通过Edit->Rendering->Material->Convert Selected Built-In Material to URP解決,部分自己编写的Shader需要按照URP的Shader做法修改一遍,主要是几个#Include会有差别,详见URP shader与Built-in shader差异和URP shader模板,这里引用一部分。
差异:
1.Tags里需要添加“RenderPipeline”=“UniversalPipeline”
2.CGPROGRAM改为HLSLPROGRAM,ENDCG改为ENDHLSL,CGINCLUDE改为HLSLINCLUDE
3.fixed4已不支持,最低half4(测试版本2022.2.8)
4.URP内置的库文件路径:可编程渲染管线的shader库 #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/..." 通用渲染管线的shader库 #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/..." 最常用的: #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
二.URP本地化
安装好的URP资源在Packages下,主要内容为Core RP Library(SRP核心)和Universal RP,其中Core RP Library会有一些Packages依赖,在移除并删除Packages下的URP包后需要重新导入依赖的Packages,这部分将会在后文提到,我们首先关注Core RP和Universal RP两个包,右键->Show In Explorer(在资源管理器中打开)。
在Assets下建立一个文件夹来保存本地化的URP管线,这里我取名LocalURP。
URP的两个包里包含有多个程序集(*.asmdef),程序集之间具有复杂的引用关系,通过GUID来标识另一个程序集,我在第一次操作时出现了移动包位置之后程序集引用关系丢失的情况,这时推荐重新来过,否则需要手动去配置程序集之间的引用关系,有点麻烦。
为了避免在本地化时丢失程序集之间的引用,我们需要在资源管理器下把URP的两个文件夹拖到LocalURP下后尽快回到Unity编辑器下,通过一次编译来恢复这些引用。如无意外,我们会意外发现有很多的报错,并且此时如果有使用URP的Shader的话,会变成BUG紫。
(这里整个视图都变BUG紫是因为有屏幕空间的后处理Shader报错)
三.解决报错
在编程的语境中,报错实际上是程序自我诊断与优化的宝贵信号,它有助于开发者及时发现问题、定位错误根源,并据此调整代码逻辑,从而提升程序的健壮性和稳定性。因此,对于追求高质量代码的程序而言,报错不仅不是坏事,反而是推动其不断完善与进步的良性机制。
------沃兹.基硕得
这里的报错主要来自于两个方面,我们将一一排除错误,将管线恢复正常使用。
报错一:程序集引用关系为空,此时建议重新来过,我第一次操作时出现了这个情况,感觉可能与移除Packages下的包时发生过编译有关系,建议一次性将包移动到Assets下后再进行编译。
(此时已经可以手动更改程序集关系了)
报错二:原本代码中使用Packages的相对路径来定位ShaderLibrary,以及在代码中也有若干处使用了Packages的相对路径,这里我们需要更改代码中的Packages相对路径,如unlit.shader中有:
Pass
{
Name "Unlit"
HLSLPROGRAM
...
#include "Packages/com.unity.render-pipelines.universal/Shaders//UnlitInput .hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/ UnlitForwardPass.hlsl"
ENDHLSL
}
其他还有用到Packages/com.unity.render-pipelines.core的源码,如ProbeVolumeSubdivide等。这里我们需要将这两个包的路径更改为我们Assets下的对应包,使用Visual Studio的替换功能快速替换整个项目的所有代码。
(记得别多或者少斜杠"/")
全部保存后,回到Unity进行编译,按前一次操作的情况,此时还会有一些零零散散的路径没被替换,一个个进去看看问题,替换掉即可。我这里的项目是个新的项目,看起来已经可以了。
考虑到把URP拉到本地以后,有可能会出现想要对管线进行增删改查,此时可以删除Package Manager中的Universal RP包,并删除拉到本地URP的package.json,万一以后增改很成功,可以再发一个包(做梦)。这里Core包还依赖于三个另外的Package,删除Universal RP之后还需要再把这三个Package装回来,至此URP已经被拉到本地,与其他我们自己编写的SRP一样,可以进行任意改写。
(删除Universal RP后,这三个程序集还要通过PackageManager安装)
四.URP的主要运行流程
在完成了前三节操作后,我们现在可以快乐地分析URP管线的具体内容,并与之前我们自己编写的SRP管线进行对照,深入理解渲染管线的每一个环节做了什么。
现在,URP的源码就像我们自己编写的SRP源码一样呈现在眼前,我们可以在Visual Studio里找到URP的相关程序集,通过Visual Studio工具或者第三方工具对源码进行分析,并且直接对URP的逻辑进行改写,观察删改代码对渲染结果的影响。
在创建URP管线的时候,我们首先创建Universal Renderer Data 和Universal Renderer PipeLine Asset,因此我们看源码时也从这两者入手,再逐步延伸开去。
(ScriptableObject类提供了编辑器界面,SRP应该另有代码来显示上图的界面)
从上图的操作流程可知,Unity引擎首先获取到的是RenderPipelineAsset,从RenderPipelineAsset中再去RendererData。UniversalRendererData中继承自ScriptableRendererData的Create方法创建了UniversalRenderer,UniversalRenderPipelineAsset继承自RenderPipelineAsset的CreatePipeline方法创建了UniversalRenderPipeLine,URP管线的主要流程就在这4个类中(其中UniversalRenderPipeline标识为Partial,其实现分布在UniversalRenderPipeline和UniversalRenderPipelineCore中)。
在SRP中,每一帧的渲染入口从RenderPipeline的Render()函数开始,对应URP的情况,直接来查看UniversalRenderPipeline的Render()。
protected override void Render(ScriptableRenderContext renderContext, List<Camera> cameras)
{
//这里我删掉了大部分判断平台、添加监视器Profiler的逻辑,剩下的主结构简单易懂
//唤起当前帧的渲染进程
BeginContextRendering(renderContext, cameras);
//设置全局参数 如灯光等
...
SetupPerFrameShaderConstants();
//相机按深度排序
SortCameras(cameras);
//遍历渲染每一个摄像机
for (int i = 0; i < cameras.Count; ++i)
{
var camera = cameras[i];
if (IsGameCamera(camera))
{
//游戏主摄像机的CameraStack功能,将Stack里的摄像机渲染出来的图像叠加到主摄像机
RenderCameraStack(renderContext, camera);
}
else
{
//更新体积框架、渲染单个摄像机
BeginCameraRendering(renderContext, camera);
UpdateVolumeFramework(camera, null);
RenderSingleCamera(renderContext, camera);
EndCameraRendering(renderContext, camera);
}
}
//结束当前帧的渲染
EndContextRendering(renderContext, cameras);
}
具体的单个摄像机渲染各个pass的流程在RenderSingleCamera()中
/// <summary>
/// 渲染单个相机
/// </summary>
public static void RenderSingleCamera(ScriptableRenderContext context, Camera camera)
{
//对于主摄像机,获取额外的相机参数
UniversalAdditionalCameraData additionalCameraData = null;
if (IsGameCamera(camera))
camera.gameObject.TryGetComponent(out additionalCameraData);
//初始化相机参数
InitializeCameraData(camera, additionalCameraData, true, out var cameraData);
//执行渲染单个摄像机,另一个重载
RenderSingleCamera(context, cameraData, cameraData.postProcessEnabled);
}
/// <summary>
/// 执行渲染单个相机
/// 这里的代码都去除掉了判断平台及看起来比较棘手的代码,整体的逻辑会更清晰
/// </summary>
static void RenderSingleCamera(ScriptableRenderContext context, CameraData cameraData, bool anyPostProcessingEnabled)
{
Camera camera = cameraData.camera;
var renderer = cameraData.renderer;
//没有渲染器或者渲染的摄像机无效
if (renderer == null || !TryGetCullingParameters(cameraData, out var cullingParameters))
{
return;
}
ScriptableRenderer.current = renderer;
bool isSceneViewCamera = cameraData.isSceneViewCamera;
//srp熟悉的流程
CommandBuffer cmd = CommandBufferPool.Get();
CommandBuffer cmdScope = cameraData.xr.enabled ? null : cmd;
renderer.Clear(cameraData.renderType);
//获取相机剔除参数,进行剔除
renderer.OnPreCullRenderPasses(in cameraData);
renderer.SetupCullingParameters(ref cullingParameters, ref cameraData);
context.ExecuteCommandBuffer(cmd); // Send all the commands enqueued so far in the CommandBuffer cmd, to the ScriptableRenderContext context
cmd.Clear();
var cullResults = context.Cull(ref cullingParameters);
//根据剔除结果初始化渲染数据
//这个函数很值得点进去看,这里填充了光照和阴影数据
InitializeRenderingData(asset, ref cameraData, ref cullResults, anyPostProcessingEnabled, out var renderingData);
//renderer是UniversalRenderer类,都连上了
renderer.Setup(context, ref renderingData);
renderer.Execute(context, ref renderingData);
//...
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
context.Submit();
ScriptableRenderer.current = null;
}
Renderer是UniversalRenderer类,由以上代码可以得出,渲染单个摄像机的各个pass的流程就在UniversalRenderer.Setup()和ScriptableRenderer.Execute()中,事不宜迟,立马去Renderer的实现。
首先映入眼帘的是一大堆继承ScriptableRenderPass类的字段,每一个Pass类重写了ScriptableRenderPass的OnCameraSetup()、Execute()、OnCameraCleanup()方法,通过Setup()函数控制传入RenderTexture和传出RenderTexture,每一个Pass的过程代码量都不小,中间的具体细节暂且按下不表。
首先我们来看ScriptableRenderer.Execute(),这个方法是SRP的方法,相对来说渲染的业务代码更少,框架代码偏多,更容易看一些。主要逻辑是将队列中的Pass进行排序,分Block进行渲染,在Block与Block之间插入一些其他的渲染目标。
/// <summary>
/// 执行渲染
/// </summary>
public void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
//...
ref CameraData cameraData = ref renderingData.cameraData;
Camera camera = cameraData.camera;
CommandBuffer cmd = CommandBufferPool.Get();
// TODO: move skybox code from C++ to URP in order to remove the call to context.Submit() inside DrawSkyboxPass
// Until then, we can't use nested profiling scopes with XR multipass
CommandBuffer cmdScope = renderingData.cameraData.xr.enabled ? null : cmd;
//这里调用了每一个Pass的OnCameraSetup()
InternalStartRendering(context, ref renderingData);
//清除上一帧的渲染状态
ClearRenderingState(cmd);
//设置Shader中使用到的_Time,_DeltaTime, _SmoothDeltaTime
SetShaderTimeValues(cmd, time, deltaTime, smoothDeltaTime);
//按照各个Pass的渲染顺序进行排序,有写过URP后处理效果的话可以记得有一个RenderPassEvent参数可以选择,在这个Enum里可以看到各个Pass渲染的顺序
SortStable(m_ActiveRenderPassQueue);
//...
//设置光照
SetupLights(context, ref renderingData);
//为了方便阅读,这里我提取了渲染各个Block的方法为MainRenderingxxxx(),Srp这里还穿插了一些其他渲染目标,如Gizmos等
MainRenderingBeforeRenderBlocks(context, ref renderingData, renderBlocks);
cameraData = SetupCamera(context, cameraData, camera, cmd, time, deltaTime, smoothDeltaTime);
context.ExecuteCommandBuffer(cmd);
cmd.Clear();
BeginXRRendering(cmd, context, ref renderingData.cameraData);
// In the opaque and transparent blocks the main rendering executes.
MainRenderingOpaque(context, ref renderingData, renderBlocks);
MainRenderingTransparent(context, ref renderingData, renderBlocks);
#if ENABLE_VR && ENABLE_XR_MODULE
if (cameraData.xr.enabled)
cameraData.xr.canMarkLateLatch = false;
#endif
DrawGizmosAfterRendering(context, drawGizmos, camera);
MainRenderingAfterRendering(context, ref renderingData, renderBlocks);
EndXRRendering(cmd, context, ref renderingData.cameraData);
DrawWireOverlay(context, camera);
DrawGizmosPostImageEffects(context, drawGizmos, camera);
InternalFinishRendering(context, cameraData.resolveFinalTarget);
DisposeAllPass();
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
UniversalRenderer.Setup()主要将各个Pass通过ScriptableRender.EnqueuePass()加入到队列中,从上面这段代码可知,在ScriptableRender.Execute()中还会对Pass进行排序,因此Setup()入队的顺序并不代表最终渲染的结果。这个函数非常的长,这里我们只摘取部分Pass的代码。
/// <inheritdoc />
public override void Setup(ScriptableRenderContext context, ref RenderingData renderingData)
{
//...
if (mainLightShadows)
EnqueuePass(m_MainLightShadowCasterPass);
if (additionalLightShadows)
EnqueuePass(m_AdditionalLightsShadowCasterPass);
//...
if (this.actualRenderingMode == RenderingMode.Deferred)
{
//这里还有延迟渲染
EnqueueDeferred(ref renderingData, requiresDepthPrepass, renderPassInputs.requiresNormalsTexture, mainLightShadows, additionalLightShadows);
}
else
{
//...
//正向渲染渲染不透明物体
EnqueuePass(m_RenderOpaqueForwardPass);
}
//...
if (requiresDepthCopyPass)
{
//...
//深度相机渲染深度贴图Pass
EnqueuePass(m_CopyDepthPass);
}
//...
//透明Pass前的Pass
if (transparentsNeedSettingsPass)
{
EnqueuePass(m_TransparentSettingsPass);
}
//...
//渲染透明物体
EnqueuePass(m_RenderTransparentForwardPass);
//...
}
通过注释掉UniversalRenderer.Setup()中的一些Enqueue()调用,我们可以得到一些错误的渲染结果,以此加深我们的渲染知识。如注释掉EnqueuePass(m_RenderOpaqueForwardPass),我们会发现场景里的Cube不再被渲染,但是依然看不到在Cube背后本该被渲染的Gizmos(可以理解为剔除依然正确);注释掉DepthNormalPass后,再也看不到场景视角中Gizmos网格,等等。
五.URP相关的其他感兴趣的点
在我们写Shader的时候,经常会用到诸如_Time、_WorldSpaceCameraPos、unity_BillboardNormal等Property,或者使用关键词进行#pragma multi_compile如
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE
#pragma multi_compile _ _SHADOWS_SOFT
这些Property和关键词也是通过渲染管线进行设置的,可以通过UniversalRenderPipelineCore中的ShaderPropertyId和ShaderKeywordStrings查找其引用,如_Time系列参数在ScriptableRenderer.SetShaderTimeValues中设置
void SetShaderTimeValues(CommandBuffer cmd, float time, float deltaTime, float smoothDeltaTime)
{
float timeEights = time / 8f;
float timeFourth = time / 4f;
float timeHalf = time / 2f;
// Time values
Vector4 timeVector = time * new Vector4(1f / 20f, 1f, 2f, 3f);
Vector4 sinTimeVector = new Vector4(Mathf.Sin(timeEights), Mathf.Sin(timeFourth), Mathf.Sin(timeHalf), Mathf.Sin(time));
Vector4 cosTimeVector = new Vector4(Mathf.Cos(timeEights), Mathf.Cos(timeFourth), Mathf.Cos(timeHalf), Mathf.Cos(time));
Vector4 deltaTimeVector = new Vector4(deltaTime, 1f / deltaTime, smoothDeltaTime, 1f / smoothDeltaTime);
Vector4 timeParametersVector = new Vector4(time, Mathf.Sin(time), Mathf.Cos(time), 0.0f);
cmd.SetGlobalVector(ShaderPropertyId.time, timeVector);
cmd.SetGlobalVector(ShaderPropertyId.sinTime, sinTimeVector);
cmd.SetGlobalVector(ShaderPropertyId.cosTime, cosTimeVector);
cmd.SetGlobalVector(ShaderPropertyId.deltaTime, deltaTimeVector);
cmd.SetGlobalVector(ShaderPropertyId.timeParameters, timeParametersVector);
}
这点实际上是URP管线的Shader和built-in管线的Shader编写细节上有所不同的原因,确切的说每一个不同的管线之间Shader都会有所不同,最少也会像之前解决的那个Package引用问题一样,在引用路径上会有所差别。