Ever17's Studio.

【Unity-URP】快速入门精确物体屏幕空间描边

字数统计: 2.6k阅读时长: 12 min
2021/07/07 Share

前言

​ 游戏中,我们常常能看到为了给玩家更好的选择反馈(知道自己到底选中的是哪个物体),会给角色一个描边,而且大部分DCC或者引擎都是在scene窗口选择会有描边,如下图:

1

但是这个描边常常都是只描角色轮廓,那么法线外扩/边缘光描边等等都实现不了这种效果,而纯屏幕空间描边也会有很多奇怪的描边,因为屏幕空间描边本身是基于高通滤波的,越高频越也就是越到了分界处越容易归到描边区。

​ 那么如何才能只描轮廓线呢?所以我们显然需要再原本的屏幕空间的做法上做改进。

​ 本篇内容难度相对其他的屏幕空间的效果来说,简直是张飞吃豆芽,而且由于实现这种效果会叒叒叒用到了rendererfeature,所以会更多重点放在思路以及rendererfeature相关内容上

原理

​ 首先我们需要知道原理,为什么常规屏幕空间检测无法实现这种描边,以shader入门精要中的做法为例,用sobel算子算梯度,符合条件的就是边缘,如果内部有高频信息的出现,如下图2,会导致那一部分也会被检测出来导致被错误的描上了边。所以如图3,为了实现边缘的描边,我们只需要给物体一个纯色,那么描边岂不是轻而易举?而且纯色的加入导致也不需要sobel算子或者罗伯特算子了,因为这些算子是为了获得精确的边缘,而纯色本身就没什么误差,简单的计算几个方向的差,只要不是0就必然是边缘了。

  总结一下,获得边缘描边分3步,1.获得屏幕上对应物体的RT给对应的物体一个纯色  2.描边  3.合并到屏幕RT

2

开搞开搞

又是rendererfeature

新建一个rendererfeature。之前的两篇屏幕空间反射讲了部分rendererfeature的基础,这次就跳过了。

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
[Serializable]
public enum RTSize{
RT_128=1,
RT_256 =2,
RT_512 =3,
RT_1024 =4,

}
[Serializable]
public class SSOutlineSettings
{
[Header("Render Params")]
public RTSize RTSize = RTSize.RT_512;
public LayerMask HitLayer;
public Material SSOutlineMaterial;
[Range(0, 5000)]
public int QueueMin = 1000;
[Range(0, 5000)]
public int QueueMax = 3000;
public RenderPassEvent RenderPassEvent = RenderPassEvent.AfterRenderingTransparents;
[Space(10)]
[Header("EdgeDetect Params")]
public float EdgeDetectSampleDistance=1;
[ColorUsage(true, true)]
public Color EdgeColor = Color.white;
}

首先是面板需要暴露的参数准备一下,其中RTSize是因为本身描边不一定需要和屏幕RT一样的分辨率,可以适当降分辨率节省性能。Layermask是为了指定对应需要渲RT的Layer,另外的几个参数和layermask一样都是为了context.draw准备的,是画RT的必要设置。EdgeDetectSampleDistance是偏移的长度因子,偏移的越大那就导致边缘检测描边长度会增加。

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
public void Setup(ScriptableRenderer renderer)
{
dest = renderer.cameraColorTarget;

}
public CustomRenderPass(SSOutlineSettings ssOutlineSettings)
{
this.ssOutlineSettings = ssOutlineSettings;
RenderQueueRange renderQueueRange = new RenderQueueRange(ssOutlineSettings.QueueMin, ssOutlineSettings.QueueMax);
ssOutlineFilteringSettings = new FilteringSettings(renderQueueRange, ssOutlineSettings.HitLayer);
this.renderPassEvent = ssOutlineSettings.RenderPassEvent;


}
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
cameraDescriptor=cameraTextureDescriptor;
aspect = (float)Screen.width / Screen.height;
SSOutlineTexureID = Shader.PropertyToID( "_SSOutlineTexure");
EdgeTexureID = Shader.PropertyToID("_EdgeDetectTexture");
rtSize =128<<(int)(ssOutlineSettings.RTSize-1);

descriptor = new RenderTextureDescriptor(Mathf.CeilToInt(rtSize * aspect), rtSize, RenderTextureFormat.R8, 8); //depthBufferBits必须加上 否则会出现排序问题



cmd.GetTemporaryRT(SSOutlineTexureID, descriptor, FilterMode.Bilinear);
ConfigureTarget(SSOutlineTexureID);
ConfigureClear(ClearFlag.All, Color.black);



}

因为这里分辨率我们不一定是用的相机分辨率,这就导致了我们不能用相机的RenderTextureDescriptor,所以需要自己构建一个Descriptor用于创建临时RT(里面有个坑,那就是depthBufferBits,平时习惯用相机的RenderTextureDescriptor,这次一开始写的时候没填depthBufferBits,没有报错但是最后的结果会有排序问题,估计是不填不会绑定深度图导致排序和drawcall先后顺序有关)。

1
2
3
4
5
6
7
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{

DrawSSOutlineTexture(context,ref renderingData);//只渲染对应layer的黑底白图用于边缘检测
DoEdgeDetect(context); //边缘检测
DoMerge(context); //合并
}

接着我们开始写核心逻辑,execute函数是自定义渲染的入口。

纯色buffer

先开搞角色白色黑底的RT,我们指定一个layer,包含在FilteringSettings中,不符合FilteringSettings的都会被过滤掉。临时RT只画这个layer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#region 只渲染hitlayer层
void DrawSSOutlineTexture(ScriptableRenderContext context, ref RenderingData renderingData)
{
CommandBuffer cmd = CommandBufferPool.Get("Draw SSOutline Texture");
using (new ProfilingScope(cmd, new ProfilingSampler("Draw SSOutline Texture")))
{

drawingSettings = CreateDrawingSettings(ssOutlineTagID, ref renderingData, SortingCriteria.CommonOpaque);
drawingSettings.overrideMaterial = ssOutlineSettings.SSOutlineMaterial;
drawingSettings.overrideMaterialPassIndex = 0;
context.DrawRenderers(renderingData.cullResults, ref drawingSettings, ref ssOutlineFilteringSettings);

}
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);

}
#endregion

对应的shader为

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
45
46
47
48
49
50
51
52
53
54
//======================================================0
Pass
{
Tags { }

//Pass 0
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag


struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

struct v2f
{
float2 uv : TEXCOORD0;
#ifdef ENABLE_DEPTH_CULLING
float4 ScreenPos:TEXCOORD1;

#endif
float4 vertex : SV_POSITION;
};
CBUFFER_START(UnityPerMaterial)

CBUFFER_END
TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex);
TEXTURE2D(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture);
v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex);

o.uv =v.uv;



return o;
}

half4 frag (v2f i) : SV_Target
{

half4 col = 1;



return col;
}
ENDHLSL
}

frameDebug一下康康

3

ok,没问题!

开始边缘检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#region 边缘检测
void DoEdgeDetect(ScriptableRenderContext context)
{
CommandBuffer cmd = CommandBufferPool.Get("Final SSOutline");
using (new ProfilingScope(cmd, new ProfilingSampler("Final SSOutline")))
{

descriptor.colorFormat = RenderTextureFormat.ARGB32;

descriptor.msaaSamples = UniversalRenderPipeline.asset.msaaSampleCount;
cmd.GetTemporaryRT(EdgeTexureID, descriptor, FilterMode.Bilinear);
ssOutlineSettings.SSOutlineMaterial.SetFloat("_SampleDistance", ssOutlineSettings.EdgeDetectSampleDistance);
//ssOutlineSettings.SSOutlineMaterial.SetFloat("_Treshold", ssOutlineSettings.Treshold);
ssOutlineSettings.SSOutlineMaterial.SetColor("_OutlineColor", ssOutlineSettings.EdgeColor);

cmd.Blit(SSOutlineTexureID, EdgeTexureID,ssOutlineSettings.SSOutlineMaterial,1);
cmd.SetGlobalTexture("_EdgeDetectTexture", EdgeTexureID);
cmd.ReleaseTemporaryRT(EdgeTexureID);
}
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
#endregion

shader对应部分为

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
45
46
47
48
49
50
51
52
53
54
55
//======================================================1
Pass
{ //Pass 1 边缘检测描边
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag



struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

struct v2f
{
float2 uv : TEXCOORD0;
float4 uv_offset:TEXCOORD1;
float4 vertex : SV_POSITION;
};
CBUFFER_START(UnityPerMaterial)
//
float2 _MainTex_TexelSize;
float _SampleDistance;
float4 _OutlineColor;
//half _Treshold;
CBUFFER_END
TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex);

v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex);
o.uv =v.uv;

o.uv_offset.xy=o.uv+float2(-_MainTex_TexelSize.x,-_MainTex_TexelSize.y)*_SampleDistance;
o.uv_offset.zw=o.uv+float2(_MainTex_TexelSize.x,-_MainTex_TexelSize.y)*_SampleDistance;
return o;
}

half4 frag (v2f i) : SV_Target
{

half col1 = SAMPLE_TEXTURE2D(_MainTex,sampler_MainTex,i.uv).r;
half col2 = SAMPLE_TEXTURE2D(_MainTex,sampler_MainTex,i.uv_offset.xy).r;
half col3 = SAMPLE_TEXTURE2D(_MainTex,sampler_MainTex,i.uv_offset.zw).r;
half diff = abs(col1*2-col2-col3);

half4 outline=_OutlineColor*diff;
return outline;
}
ENDHLSL
}

这里就是用了之前说的,因为是纯色没什么检测上的难度,最简单的三个点做个差就拿下了。

merge!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#region merge
void DoMerge(ScriptableRenderContext context)
{



CommandBuffer cmd = CommandBufferPool.Get("SSOutline Merge");
using (new ProfilingScope(cmd, new ProfilingSampler("SSOutline Merge")))
{
int mergeID=Shader.PropertyToID("_MergeRT");
cmd.GetTemporaryRT(mergeID,cameraDescriptor,FilterMode.Bilinear);
cmd.Blit(dest, mergeID, ssOutlineSettings.SSOutlineMaterial, 2);
cmd.Blit(mergeID,dest);
cmd.ReleaseTemporaryRT(mergeID);

}
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);

}
#endregion
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
Pass
{ //Pass 2 merge
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag



struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

struct v2f
{
float2 uv : TEXCOORD0;

float4 vertex : SV_POSITION;
};

TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex);
TEXTURE2D(_EdgeDetectTexture); SAMPLER(sampler_EdgeDetectTexture);

v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex);
o.uv =v.uv;


return o;
}

half4 frag (v2f i) : SV_Target
{

half4 oringinCol = SAMPLE_TEXTURE2D(_MainTex,sampler_MainTex,i.uv);
half4 edgeCol=SAMPLE_TEXTURE2D(_EdgeDetectTexture,sampler_EdgeDetectTexture,i.uv);
half3 col=lerp(oringinCol.rgb,edgeCol.rgb,edgeCol.a);
return half4(col,1);
}
ENDHLSL
}

处理问题

深度剔除问题

以上我们做了一个最基础的版本,但是存在不少问题,首先是因为我们画RT不考虑深度剔除,会导致即使物体被挡住,也会画

4

所以我们需要加入一个基于深度图采样的剔除功能解决方岸,而如果使用了深度图,那么需要我们的RT也具有摄像机相同的分辨率像素需要完全对应。

HDR支持

一般来说描边如果支持HDR会使得选择更加显眼,同样HDR+Bloom自带模糊,能让本来就精度不高的描边锯齿感弱化,同样,为了减少锯齿RT会开启MSAA

解决方案

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
[Serializable]
public enum DepthCullingMode
{
RTSizeSameToOpaqueTex_HighQuality=1,
ForceUseRTSize=2,
NoCulling = 3,
}
[Serializable]
public enum RTSize{
RT_128=1,
RT_256 =2,
RT_512 =3,
RT_1024 =4,

}
[Serializable]
public class SSOutlineSettings
{
[Header("Render Params")]
public RTSize RTSize = RTSize.RT_512;
public LayerMask HitLayer;
public Material SSOutlineMaterial;
[Range(0, 5000)]
public int QueueMin = 1000;
[Range(0, 5000)]
public int QueueMax = 3000;
public RenderPassEvent RenderPassEvent = RenderPassEvent.AfterRenderingTransparents;
[Space(10)]
[Header("EdgeDetect Params")]
public float EdgeDetectSampleDistance=1;
//[Range(0, 1)] public float Treshold = 0.1f;
[ColorUsage(true, true)]
public Color EdgeColor = Color.white;
public bool EnableHDREdgeColor = false;
[Header("DepthCulling Params")]
//public bool EnableDepthCulling = false;
public float ZBias = 0.015f;
public DepthCullingMode DepthCullingMode=DepthCullingMode.NoCulling;
}
1
2
3
4
5
6
7
8
if (!((int)ssOutlineSettings.DepthCullingMode==1))
{
descriptor = new RenderTextureDescriptor(Mathf.CeilToInt(rtSize * aspect), rtSize, RenderTextureFormat.R8, 8); //depthBufferBits必须加上 否则会出现排序问题
}
else
{
descriptor=new RenderTextureDescriptor(Screen.width, Screen.height, RenderTextureFormat.R8, 8); //如果需要深度图做剔除遮挡像素,需要保持分辨率相等,否则会有分辨率问题导致的边缘错误
}
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
#region 只渲染hitlayer层
void DrawSSOutlineTexture(ScriptableRenderContext context, ref RenderingData renderingData)
{
CommandBuffer cmd = CommandBufferPool.Get("Draw SSOutline Texture");
using (new ProfilingScope(cmd, new ProfilingSampler("Draw SSOutline Texture")))
{
if((int)ssOutlineSettings.DepthCullingMode==1|| (int)ssOutlineSettings.DepthCullingMode == 2 )
{
ssOutlineSettings.SSOutlineMaterial.EnableKeyword("ENABLE_DEPTH_CULLING");
}
else
{
ssOutlineSettings.SSOutlineMaterial.DisableKeyword("ENABLE_DEPTH_CULLING");
}

drawingSettings = CreateDrawingSettings(ssOutlineTagID, ref renderingData, SortingCriteria.CommonOpaque);
drawingSettings.overrideMaterial = ssOutlineSettings.SSOutlineMaterial;
drawingSettings.overrideMaterialPassIndex = 0;
context.DrawRenderers(renderingData.cullResults, ref drawingSettings, ref ssOutlineFilteringSettings);

}
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);

}
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
#region 边缘检测
void DoEdgeDetect(ScriptableRenderContext context)
{
CommandBuffer cmd = CommandBufferPool.Get("Final SSOutline");
using (new ProfilingScope(cmd, new ProfilingSampler("Final SSOutline")))
{
if ((int)ssOutlineSettings.DepthCullingMode==1)
{
descriptor.width = Mathf.CeilToInt(rtSize * aspect);
descriptor.height = rtSize;

}
if (ssOutlineSettings.EnableHDREdgeColor)
{
descriptor.colorFormat = RenderTextureFormat.ARGBHalf;
}
else
{
descriptor.colorFormat = RenderTextureFormat.ARGB32;
}
descriptor.msaaSamples = UniversalRenderPipeline.asset.msaaSampleCount;
cmd.GetTemporaryRT(EdgeTexureID, descriptor, FilterMode.Bilinear);
ssOutlineSettings.SSOutlineMaterial.SetFloat("_SampleDistance", ssOutlineSettings.EdgeDetectSampleDistance);
//ssOutlineSettings.SSOutlineMaterial.SetFloat("_Treshold", ssOutlineSettings.Treshold);
ssOutlineSettings.SSOutlineMaterial.SetColor("_OutlineColor", ssOutlineSettings.EdgeColor);

cmd.Blit(SSOutlineTexureID, EdgeTexureID,ssOutlineSettings.SSOutlineMaterial,1);
cmd.SetGlobalTexture("_EdgeDetectTexture", EdgeTexureID);
cmd.ReleaseTemporaryRT(EdgeTexureID);
}
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
#endregion

对应的纯色RT深度检测部分shader,做一个深度比较,但是受限于精度,我们和shadowmap一样引入bias来减少瑕疵

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
v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex);

o.uv =v.uv;

#ifdef ENABLE_DEPTH_CULLING
o.ScreenPos=ComputeScreenPos(o.vertex);
o.ScreenPos.z=-mul(UNITY_MATRIX_V,mul(UNITY_MATRIX_M,v.vertex)).z;
#endif

return o;
}

half4 frag (v2f i) : SV_Target
{

half4 col = 1;

#ifdef ENABLE_DEPTH_CULLING
float depth=SAMPLE_TEXTURE2D(_CameraDepthTexture,sampler_CameraDepthTexture,i.ScreenPos.xy/i.ScreenPos.w).r;
depth=LinearEyeDepth(depth,_ZBufferParams);

col=abs(i.ScreenPos.z-depth)<_ZBias*depth?1:0;





#endif

return col;
}
ENDHLSL
}

5

芜湖!问题就解决了。

再提一嘴

6

吃鸡里的描边和这个类似但不完全一样,就一个地方有区别,眼尖的旁友估计已经发现了,他们被遮挡的部分轮廓不会描边出来,这是因为他们是在描边那个部分做的深度检测,也是就是在第二个Pass做的,把深度未通过的直接pass了,而我们在RT的时候pass了被遮挡的部分,导致了这部分依然会被描出来,但是吃鸡这种做法代价是多一个通道存自己的深度,开销多一点,这得看项目的取舍了。

原文作者:luqc

原文链接:https://ever17-luqc.github.io/SSOutline/

发表日期:July 7th 2021, 9:19:04 pm

更新日期:July 11th 2021, 5:44:25 pm

版权声明:本文采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可

CATALOG
  1. 1. 前言
  2. 2. 原理
  3. 3. 开搞开搞
    1. 3.1. 又是rendererfeature
    2. 3.2. 纯色buffer
    3. 3.3. 开始边缘检测
    4. 3.4. merge!
    5. 3.5. 处理问题
    6. 3.6. 深度剔除问题
    7. 3.7. HDR支持
    8. 3.8. 解决方案
    9. 3.9. 再提一嘴