在Unity轻量管线里实现多pass的shader一直是头疼的问题,两次还可以正常显示,但超过两次的就GG。
这次策划给了个需求,要求物体的描边在被遮挡的时候也要显示,原来的描边会被遮挡,不能满足需要,而且不能使用屏幕后期,那就得用上stencil了。可这样就得至少三次pass,第一次绘制主物体,第二次用stencil绘制遮挡物体,第三次扩大物体,绘制边缘。如果这么做一方面轻量管线不支持,需要通过SRP实现,另一方面控制起来会比较麻烦。正好之前看到Unity推文介绍Custom Forward Renderer功能,研究了下。
国内地址www.bilibili.com下面简单介绍下流程。
0x01 测试环境
Unity2019.3.4f1
URP 7.3.1
0x02 方案一:层方案
2.1 编写描边代码
Shader "Unlit / OutLine"
{
Properties
{
_Color("outline color",color) = (1,1,1,1)
_Width("outline width",range(0,1)) = 0.2
}
Subshader
{
Pass
{
Tags {"LightMode" = "LightweightForward" "RenderType" = "Opaque" "Queue" = "Geometry + 10"}
//Tags可不添加,只是为了演示
colormask 0 //不输出颜色
ZWrite Off
ZTest Off
Stencil
{
Ref 1
Comp Always
Pass replace
}
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.lightweight/ShaderLibrary/Core.hlsl"
struct appdata
{
float4 vertex: POSITION;
};
struct v2f
{
float4 vertex: SV_POSITION;
};
v2f vert(appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex.xyz);
return o;
}
half4 frag(v2f i) : SV_Target
{
return half4 (0.5h, 0.0h, 0.0h, 1.0h);
}
ENDHLSL
}
Pass
{
Tags {"RenderType" = "Opaque" "Queue" = "Geometry + 20"}
ZTest off
Stencil {
Ref 1
Comp notEqual
Pass keep
}
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.lightweight/ShaderLibrary/Core.hlsl"
struct appdata
{
float4 vertex: POSITION;
float3 normal:NORMAL;
};
struct v2f
{
float4 vertex: SV_POSITION;
};
half4 _Color;
half _Width;
v2f vert(appdata v)
{
v2f o;
//v.vertex.xyz += _Width * normalize(v.vertex.xyz);
v.vertex.xyz += _Width * normalize(v.normal);
o.vertex = TransformObjectToHClip(v.vertex.xyz);
return o;
}
half4 frag(v2f i) : SV_Target
{
return _Color;
}
ENDHLSL
}
}
}
2.2 创建材质球,命名为“Outline”,效果如图:
这种描边会有断边和混合的问题,这里不展开,解决办法在此 硬边描边断边问题。
2.3 创建渲染层级
我这里添加了叫Outline的层级,用来处理满足需要的描边pass。
2.4 创建Custom Renderer
创建UniversalRenderAsset后会默认新建一个叫UniversalRenderAsset_Render的RenderList,这是控制渲染路径的资源。
Opaque Layer Mask:设置不透明物体渲染的层级,原视频里通过屏蔽“Character”图层,然后在Renderer Features里对“Character”图层单独控制。这里可以屏蔽outline层,然后再RenderObject里对这个层特殊处理。
Transparent Layer Mask:同理。
Overrides:覆盖选中的层。
- Stencil:覆盖Default层的设置,特殊需求时使用。
2.5 设置Custom Renderer
点击AddRenderFeature按钮,新建两个RenderObject。之所以是两个,是因为上面描边的shader用了两个pass,第一个pass用来处理mask,第二个pass用来发现外扩填充描边。
- Name:此List的名称
- Event:设置渲染时机
- Queue:渲染队列
- Layer Mask:设置需要渲染的图层
- Shader Passes:选择当前物体渲染Pass的回调位置(Callback),后面讲这个选项
- Material:需要覆盖的材质球,
- Pass Index:选择相应的pass,实现不同效果
- Depth:覆盖原深度,官方视频就是用这个来处理遮挡效果
- Stencil:覆盖原模板缓冲,因此原shader中的模板缓冲只用来检查,还需要这里配置正确
- Camera:覆盖原相机投影(这个有意思,官方提供的例子是FPS游戏中的枪和场景的FOV不同)
上图是我对描边做的设置,我将对Outline做特殊处理,注意List的顺序就是渲染的顺序,顺序不对效果也会有偏差。
2.6 设置Universal Render Pipeline Asset
设置好Custom Renderer Data,得有入口才能做渲染。URP下默认帮你设置好默认渲染的list,我们之前也是对这个RenderObject做的处理,理解这个功能后也可以自行添加不同的UniversalRenderAsset实现特殊效果。
2.7 查看效果
首先创建测试物体,设置层级为Default。
将他们的层级设置为outline后的效果。
实现逻辑是先按照RenderList里的设置参数跑一遍默认的效果,然后对指定层级的物体做后续的自定义渲染。
2.8 性能测试
更新Unity后真躺着优化,SRP Batcher没有因为描边pass而打断,性能开销方面主要是描边本身,后续优化描边算法,让其只是srp batcher看看能不能解决合批问题。
0x02 PassName方案
3.1 编写描边代码
Shader "OutLine_type2"
{
Properties
{
_Matcap("Matcap", 2D) = "white" {}
_Color("outline color",color) = (1,1,1,1)
_Width("outline width",range(0,1)) = 0.2
[Space][Header(Ztest State)]
[Enum(UnityEngine.Rendering.CompareFunction)]_ZTest("Ztest", Float) = 0.0
}
Subshader
{
Tags{"RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" "Queue" = "Geometry" "IgnoreProjector" = "True"}
LOD 300
Cull Back
HLSLINCLUDE
#pragma target 2.0
ENDHLSL
Pass
{
//新增matcap效果
Name "Forward"
Tags { "LightMode" = "UniversalForward" }
ZWrite On
ZTest LEqual
Offset 0 , 0
HLSLPROGRAM
#pragma multi_compile_instancing
#define ASE_SRP_VERSION 999999
#pragma prefer_hlslcc gles
#pragma exclude_renderers d3d11_9x
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/ShaderGraphFunctions.hlsl"
#if ASE_SRP_VERSION <= 70108
#define REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR
#endif
#define ASE_NEEDS_VERT_NORMAL
struct VertexInput
{
float4 vertex : POSITION;
float3 ase_normal : NORMAL;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct VertexOutput
{
float4 clipPos : SV_POSITION;
#if defined(ASE_NEEDS_FRAG_WORLD_POSITION)
float3 worldPos : TEXCOORD0;
#endif
#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR) && defined(ASE_NEEDS_FRAG_SHADOWCOORDS)
float4 shadowCoord : TEXCOORD1;
#endif
#ifdef ASE_FOG
float fogFactor : TEXCOORD2;
#endif
float4 ase_texcoord3 : TEXCOORD3;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
sampler2D _Matcap;
VertexOutput vert(VertexInput v)
{
VertexOutput o = (VertexOutput)0;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
half3 ase_worldNormal = TransformObjectToWorldNormal(v.ase_normal);
o.ase_texcoord3.xyz = ase_worldNormal;
//setting value to unused interpolator channels and avoid initialization warnings
o.ase_texcoord3.w = 0;
#ifdef ASE_ABSOLUTE_VERTEX_POS
float3 defaultVertexValue = v.vertex.xyz;
#else
float3 defaultVertexValue = float3(0, 0, 0);
#endif
float3 vertexValue = defaultVertexValue;
#ifdef ASE_ABSOLUTE_VERTEX_POS
v.vertex.xyz = vertexValue;
#else
v.vertex.xyz += vertexValue;
#endif
v.ase_normal = v.ase_normal;
float3 positionWS = TransformObjectToWorld(v.vertex.xyz);
float4 positionCS = TransformWorldToHClip(positionWS);
#if defined(ASE_NEEDS_FRAG_WORLD_POSITION)
o.worldPos = positionWS;
#endif
#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR) && defined(ASE_NEEDS_FRAG_SHADOWCOORDS)
VertexPositionInputs vertexInput = (VertexPositionInputs)0;
vertexInput.positionWS = positionWS;
vertexInput.positionCS = positionCS;
o.shadowCoord = GetShadowCoord(vertexInput);
#endif
#ifdef ASE_FOG
o.fogFactor = ComputeFogFactor(positionCS.z);
#endif
o.clipPos = positionCS;
return o;
}
half4 frag(VertexOutput IN) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(IN);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(IN);
#if defined(ASE_NEEDS_FRAG_WORLD_POSITION)
float3 WorldPosition = IN.worldPos;
#endif
float4 ShadowCoords = float4(0, 0, 0, 0);
#if defined(ASE_NEEDS_FRAG_SHADOWCOORDS)
#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
ShadowCoords = IN.shadowCoord;
#elif defined(MAIN_LIGHT_CALCULATE_SHADOWS)
ShadowCoords = TransformWorldToShadowCoord(WorldPosition);
#endif
#endif
half3 ase_worldNormal = IN.ase_texcoord3.xyz;
float3 BakedAlbedo = 0;
float3 BakedEmission = 0;
float3 Color = tex2D(_Matcap, ((mul(UNITY_MATRIX_V, half4(ase_worldNormal , 0.0)).xyz * 0.5) + 0.5).xy).rgb;
float Alpha = 1;
float AlphaClipThreshold = 0.5;
#ifdef _ALPHATEST_ON
clip(Alpha - AlphaClipThreshold);
#endif
#ifdef ASE_FOG
Color = MixFog(Color, IN.fogFactor);
#endif
#ifdef LOD_FADE_CROSSFADE
LODDitheringTransition(IN.clipPos.xyz, unity_LODFade.x);
#endif
return half4(Color, Alpha);
}
ENDHLSL
}
Pass
{
Name "Mask"
Tags {
"LightMode" = "Mask" //切记这里灯光模式为Mask
"RenderType" = "Transparent"
"Queue" = "Transparent + 10"
}
Cull off
colormask 0
ZWrite Off
ZTest always
Stencil
{
Ref 1
Comp Always
Pass replace
}
}
Pass
{
Name "Fill"
Tags {
"LightMode" = "Fill" //切记这里灯光模式为Fill
"RenderType" = "Transparent"
"Queue" = "Transparent + 20"
"DisableBatching" = "True"
}
Cull Off
ZWrite on
ZTest [_ZTest]
Blend SrcAlpha OneMinusSrcAlpha
ColorMask RGB
Stencil {
Ref 1
Comp notEqual
Pass keep
}
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct appdata
{
float4 vertex: POSITION;
float3 normal: NORMAL;
float3 smoothNormal : TEXCOORD3;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct v2f
{
float4 vertex: SV_POSITION;
float4 color : COLOR;
UNITY_VERTEX_OUTPUT_STEREO
};
half4 _Color;
half _Width;
v2f vert(appdata v)
{
v2f o;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
VertexPositionInputs input = GetVertexPositionInputs(v.vertex.xyz);
//float3 normal = any(v.smoothNormal) ? v.smoothNormal : v.normal;
float3 normal = v.normal;
float3 viewPosition = input.positionVS;
float3 viewNormal = normalize(mul((float3x3)UNITY_MATRIX_IT_MV, normal));
o.vertex = mul(UNITY_MATRIX_P, float4(viewPosition + viewNormal * -viewPosition.z * _Width / 100.0, 1.0));
o.color = _Color;
return o;
}
half4 frag(v2f i) : SV_Target
{
return i.color;
}
ENDHLSL
}
}
}
与用层的方案不同,用层的方案时可以将初始效果和描边效果分开写两个shader,但用Passes Name的方案就得将初始效果和描边效果写在同一个shader里,同时切记LightMode的名字需要唯一,底层没做for循环。
3.2 创建材质球
3.3 设置Custom Renderer
这时候就要讲上面说的Shader Passes Name参数。我这里用了两种方法并且对比了其效果。
第一种方法只用一个RenderObject,但在ShaderPassesName的地方设置了两个pass的名字,会发现当有多个子物体时,描边效果会穿插。原因是只有一个RenderLoop,会对每个子物体单独绘制描边,效果就会出现堆叠,哪怕在ZTest上下功夫也会有瑕疵。
第二种方法使用两个RenderObject,分别处理两个功能Pass,得到两个RenderLoop,避免描边堆叠的问题。对于增加的RenderLoop开销问题也问了引擎,理论上开销不大。(这里有个unity的bug,ShaderPassesName列表居然不能删除,Rua鸡)
0x03 遇到的问题
使用第一种方法需要消耗一个层来做特殊效果,对于正常游戏项目,层经常吃紧,而且不同效果需要不同层,会让层更紧张。
对于描边效果而言,需求往往是针对某个物体有描边,并不希望对整体有描边效果,因此用第二种方案就需要写两个shader,一个普通效果,一个普通效果加描边效果,这会增加shader变体数量,对变体数量敏感的项目而言会不太友好。如果需求变成”我要在展示界面有妲己毛发效果,游戏中不要”,那就好办了,直接通过代码控制RenderObject开关就行。代码如下:
public class ChangePipline : MonoBehaviour
{
public ForwardRendererData forwardRenderData;
private List<ScriptableRendererFeature> _features;
private void Start()
{
_features = forwardRenderData.rendererFeatures;
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Z))
{
_features[1].SetActive(false);
}
if (Input.GetKeyDown(KeyCode.X))
{
_features[1].SetActive(true);
}
}
}
0x04 结论
Unity2019新增的Custom Forward Renderer简单实用,不需要代码就可实现在轻量管线下的多Pass功能,而且只要通过层级管理就可以。Unity提供了32个层,怕不够用??还有PassName的方案,另外还有屏幕后期这条路,这里不介绍。妲己毛发是不是感觉有救了?