Unity下的描边处理
前言
最近策划突然要做日系赛璐璐风格的卡通渲染,所以我又要去学这方面的知识。而描边则是此类风格中必要的一环。我在网上看过许多关于描边的做法,它们之中各有利弊。我怕遗忘,因此我想用这篇文章来记录一下。
本篇文章的代码会涉及Unity的内置(Build-In)渲染管线和通用渲染管线(URP)。如果其中有存在错误的地方,也请大家指出。
环境
Windows10操作系统
Unity2022.3.8f1c1
Universal RP 14.0.8
额外操作
在内置(Build-In)渲染管线中,某些描边的方法是需要一个额外的Pass来渲染描边。比如我们使用顶点扩展方法进行描边处理。但是在URP下,我们单纯的声明一个额外的Pass是没有作用的。我们必须在URP的设置中添加一个Render Feature
,然后将Shader标签中的LightMode
声明为我们所添加的Render Feature
名称。
查找项目URP设置方法:Editor->Project Setting->Graphics
。然后点击下图红框处,我们就可以在Project
窗口下找到当前项目的URP设置。
<img src = "\images\Unity下的描边处理\图1.png" style="margin:auto;"></img>
接下来我们查看刚刚找到的URP设置。我们可以在其Render -> Render List
下找到其设置数据。我们点击并查看这个设置数据就可以找到Add Render Feature
的按钮。最后我们点击它,选择Render Objects(Experimental)
。具体Render Feature
的设置如下图:
<img src = "\images\Unity下的描边处理\图2.png" style="margin:auto;"></img>
描边方法
菲涅尔反射(Fresnel Reflect)
对于菲涅尔反射,大家可以参考以下的文章链接:什么是菲涅尔 ,万物皆有菲涅尔 -
英文版 ,万物皆有菲涅尔 -
中文版 。Unity也有一篇文章在粗略的说了一下这个效果并给出了其通常的公式:Fresnel
Effect Node 。
对于菲涅尔反射,我个人认为只要记住下面的公式就好了。公式来源于上面Unity的文章。
1 2 3 4 void Unity_FresnelEffect_float(float3 Normal, float3 ViewDir, float Power, out float Out) { Out = pow((1.0 - saturate(dot(normalize(Normal), normalize(ViewDir)))), Power); }
从上面的公式中,我们可以得出一个结论:当物体表面的法线和我们观察方向的夹角到达90度阈值时,菲涅尔现象就达到最大值了。那么对于一个不存在于锋利边缘的物体(比如圆)来说,这样的方法就可以很好的描绘出物体的边缘。
Shader实现:
通用渲染管线(URP)下的实现
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 56 57 58 59 60 61 62 63 64 65 Shader "OutLine/FresnelOutLine" { Properties { _Color ("MainColor",Color) = (1,1,1,1) _OutLineColor ("OutLineColor",Color) = (0,0,0,1) _FresnelAmount ("FresenlAmount",Range(0,20)) = 0 _OutLineAmount ("OutLineAmount",Range(0,1)) = 0 } SubShader { Tags { "RenderType"="Transparent" "IgnoreProjector" = "True" "Queue" = "Transparent"} LOD 100 Pass { Tags{"LightMode"="UniversalForward"} Blend SrcAlpha OneMinusSrcAlpha HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct appdata { float4 vertex : POSITION; float4 normal : NORMAL; }; struct v2f { float4 vertex : SV_POSITION; float4 worldNormal : TEXCOORD0; float4 worldPos : TEXCOORD1; }; CBUFFER_START(UnityPerMaterial) float _FresnelAmount; float4 _Color; float4 _OutLineColor; half _OutLineAmount; CBUFFER_END v2f vert (appdata v) { v2f o; o.vertex = TransformObjectToHClip(v.vertex.xyz); o.worldNormal = float4(TransformObjectToWorldNormal(v.normal.xyz),0); o.worldPos = float4(TransformObjectToWorld(v.vertex.xyz),1); return o; } float4 frag (v2f i) : SV_Target { float3 viewDir = GetWorldSpaceNormalizeViewDir(i.worldPos.xyz); float3 normal = normalize(i.worldNormal.xyz); float fresnelVal = pow((1.0 - saturate(dot(normalize(normal), normalize(viewDir)))), _FresnelAmount); return lerp(_Color,_OutLineColor,step(1 - _OutLineAmount,fresnelVal)); } ENDHLSL } } }
表现:
<img src = "\images\Unity下的描边处理\图3.png" style="margin:auto;"></img>
内置(Build-In)渲染管线下的实现
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 56 57 58 59 60 61 62 63 64 65 66 67 Shader "Test/FresnelOutLine" { Properties { _Color ("MainColor",Color) = (1 ,1 ,1 ,1 ) _OutLineColor ("OutLineColor",Color) = (0 ,0 ,0 ,1 ) _FresnelAmount ("FresenlAmount",Range(0 ,20 )) = 0 _OutLineAmount ("OutLineAmount",Range(0 ,1 )) = 0 } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float4 normal : NORMAL; }; struct v2f { float4 vertex : SV_POSITION; float4 worldNormal : TEXCOORD0; float4 worldPos : TEXCOORD1; }; fixed4 _Color; fixed4 _OutLineColor; fixed _OutLineAmount; float _FresnelAmount; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.worldPos.xyz = mul(unity_ObjectToWorld,v.vertex); o.worldNormal.xyz = mul(v.normal.xyz,(float3x3)unity_WorldToObject); return o; } fixed4 frag (v2f i) : SV_Target { fixed3 viewDir; if (unity_OrthoParams.w == 1 ) viewDir = UNITY_MATRIX_V[2 ].xyz; else viewDir = normalize (_WorldSpaceCameraPos.xyz - i.worldPos.xyz); fixed3 normal = normalize (i.worldNormal.xyz); float fresnelVal = pow ((1.0 - saturate(dot (normalize (normal), normalize (viewDir)))), _FresnelAmount); return lerp(_Color,_OutLineColor,step (1 - _OutLineAmount,fresnelVal)); } ENDCG } } }
内置(Build-In)渲染管线下的表现
<img src = "\images\Unity下的描边处理\图5.png" style="margin:auto;"></img>
存在的问题
使用上面的方法会出现下图的问题
<img src = "\images\Unity下的描边处理\图4.png" style="margin:auto;" width="204" height="200"></img>
<img src = "\images\Unity下的描边处理\图6.png" style="margin:auto;" width="200" height="200"></img>
左图因为立方体本身法线的分布是存在锋利边缘,所以它并不能很好的被描边。右图是摄像机处于在正交渲染,摄像机位置取到摄像机世界空间的位置。所以在内置渲染管线中取摄像机位置时,我做了额外的判断。而URP中,我所调用的函数已经做了这份处理了。
顶点扩展
顶点扩展的核心思想就是将顶点朝着某些方向扩展出去(就好像放大了模型或是偏移了模型)。然后我们将扩展后的物体赋予一个颜色。最后我们再将物体原本的颜色盖上去,以此来完成描边。一般要完成这样的操作需要使用两个Pass来完成。一个Pass用来描边,一个Pass用来描绘原本的颜色。用来描边的Pass一般会只渲染背面,这是为了防止顶点扩展后将原本模型遮挡住。而如何进行扩展,这个问题也有很多不同的方法。
直接放大模型坐标
简单粗暴一点就是剔除正面直接放大模型的坐标,这样虽然可以描边但是会出现很多问题。这里我只给出关键的代码,因为我实在不推荐这样的方法。
1 float3 vertex = v.vertex.xyz * _Width;
结果如图:
<img src = "\images\Unity下的描边处理\图7.png" style="margin:auto;"></img>
通过上面的图片,我们可以发现以下几个问题:
描边不完全。第一个Cube物体少描了一个边。这是因为我们剔除了正面导致的。
描边的粗细不一致。从图中的胶嚢体中,我们可以明显地看出描边粗细的不一致。这是因为当一个点是(1,0,0)
,另一个点为(2,0,0)
时,它们同时放大两倍则变为(2,0,0)
和(4,0,0)
。这时候描边的大小就是1和2。
从第二点,我们可以看出其受中心点影响较大。图中的结果都是中心点在物体中心,所以感触不大。但是如果你开启了动态批处理,因为合并后的网格中心不在其本身中心上,那么结果就会随着_Width越大越离谱。
对于第一个问题是这个逻辑本身带有的问题,我个人认为是无法解决的(实际上接下来的一些方法也不能很好解决这个问题)。第二个问题和第三个问题,我看的文章将关键代码改为下面的样子:
1 float3 vertex = v.vertex.xyz + normalize(v.vertex.xyz) * _Width;
但是这样仍然有问题,当你的物体本身带有缩放的时候。之前的问题又回来了。而且我认为这样的方法是错误的。比如下面这段代码,运行后。你会发现按照他的想法来一条直线最终会变弯曲
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 using System.Collections.Generic;using UnityEngine;namespace Test { public class OutLineTest : MonoBehaviour { public float len = 2 ; public float h = 1 ; public int pointNum = 5 ; private void OnDrawGizmos () { Gizmos.color = Color.red; var startPos = new Vector3(len / 2 , 0 ); var endPos = new Vector3(-len / 2 , 0 ); Gizmos.DrawLine(startPos, endPos); Gizmos.color = Color.yellow; var point = new Vector3(0 , h); Gizmos.DrawLine(startPos, point); Gizmos.DrawLine(endPos, point); List<Vector3> lst = new List<Vector3>(); for (int i = 0 ; i <= pointNum; ++i) { float ratio = i *1.0f / pointNum; var pos = Vector3.Lerp(startPos, endPos, ratio); var dir = pos - point; pos += Vector3.Normalize(dir); lst.Add(pos); } Gizmos.color = Color.blue; for (int i = 1 ;i <= pointNum; ++i) { Gizmos.DrawLine(lst[i - 1 ], lst[i]); } } } }
正因为其本身许多问题,所以我并不推荐大家使用。但是这个思想还是很重要的。因为顶点扩展本身就是对模型进行放大或是偏移。
延法线扩展
这个和上面的方法一样,不过这个是沿着点位的法线来进行延伸。这个我也只展示核心的代码:
1 float3 vertex = v.vertex.xyz + v.normal.xyz * _Width;
结果如图:
<img src = "\images\Unity下的描边处理\图8.png" style="margin:auto;"></img>
从图中我们可以看到,对于那些拥有平滑法线过渡的物体(球体和胶嚢体)。它们的描边效果都不错,但是对于那些法线过渡不平滑的物体(立方体和圆柱体)。它们的描边效果就很奇怪了。其实现在有很多工具都可以让物体表面的法线平滑。有些人会将这平滑的法线存入Vertex Color
(顶点颜色),然后使用这个去做描边。
不同空间下的扩展
前面的方法,我们都是在模型空间(Model
Space)下进行处理。但是为了一些效果,有些人会将顶点扩展放到其他的空间下进行扩展。比如上面的法线扩展,有些人就会放到裁剪空间(Clip
Space)中进行.核心代码如下:(URP)
1 2 3 float3 normal = TransformWorldToHClipDir(TransformObjectToWorldNormal(v.normal),true); o.vertex = TransformObjectToHClip(v.vertex.xyz); o.vertex.xy += normal.xy / _ScreenParams.xy * o.vertex.w * _Width * 2;
PS:我参考的文章中对发现的变化是直接让法线乘上MVP矩阵。但是这样会导致一些问题,所以我改了一下方法。不过我实际测试下来,效果还是差不多。当然也可能是因为我测试的模型不够。具体关于法线转换的问题,大家可以看一下这篇文章渲染管线中的法线变换矩阵 。
即使我们将法线放置在裁剪空间下进行扩展,法线延伸本身的问题仍然得不到解决。即我们仍然需要一个平滑的法线。下面一段是我参考文章中作者的说明(原文是英文,这里我使用有道翻译 进行翻译了)
第一步,顶点位置和法向量都从对象空间转换到剪辑空间。第二步,顶点沿着它的法向量平移。因为我们现在是在二维空间中工作,所以只有顶点位置的x和y坐标会被改变。偏移量除以屏幕的宽度和高度,从而得到屏幕的纵横比。然后,偏移量乘以剪辑空间顶点位置的w分量。这样做是因为在下一阶段,剪辑空间坐标将通过所谓的透视分割转换为屏幕空间坐标,即将剪辑空间的x/y/z坐标除以剪辑空间的w坐标。因为我们希望在转换到屏幕空间后得到相同的轮廓,所以我们预先乘以这个剪辑空间w坐标,这样透视分割就不会对轮廓产生净影响。最后,偏移量乘以我们期望的轮廓宽度和因子2,这样宽度单位1将与屏幕上的1个像素对应。
这样做其实会让在不同屏幕分辨率下的描边粗细出现很明显的变化。这个你们可以自行实验一下看看。总之我个人是不喜欢这样的做法。URP下的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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 Shader "OutLine/NormalOutLineInClip" { Properties { _MainTex ("Texture", 2D) = "white" {} _Width ("Width",Float) = 0 _OutLineColor ("OutLineColor",Color) = (0,0,0,1) } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { Tags{"LightMode"="OutLine"} cull front HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct appdata { float4 vertex : POSITION; float4 normal : NORMAL; }; struct v2f { float4 vertex : SV_POSITION; }; CBUFFER_START(UnityPerMaterial) float _Width; float4 _OutLineColor; CBUFFER_END v2f vert (appdata v) { v2f o; //float3 normal = TransformWorldToHClipDir(TransformObjectToWorldNormal(v.normal),true); float3 normal = normalize(mul((float3x3)UNITY_MATRIX_VP, mul((float3x3)UNITY_MATRIX_M, v.normal))); o.vertex = TransformObjectToHClip(v.vertex.xyz); o.vertex.xy += normal.xy / _ScreenParams.xy * o.vertex.w * _Width * 2; return o; } half4 frag (v2f i) : SV_Target { return _OutLineColor; } ENDHLSL } Pass { Tags{"LightMode"="UniversalForward"} HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; CBUFFER_START(UnityPerMaterial) float4 _MainTex_ST; CBUFFER_END v2f vert (appdata v) { v2f o; o.vertex = TransformObjectToHClip(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } half4 frag (v2f i) : SV_Target { half4 col = tex2D(_MainTex, i.uv); return col; } ENDHLSL } } }
如果你有看过《Unity
Shader入门精要》,你可能还记得书中是将法线放置在了视图空间(View
Space)下。并且在扩展顶点前,我们还对顶点法线的z分量进行了一些特殊处理让其尽量不会遮挡正面模型。其核心代码如下:(内置渲染管线)
1 2 3 4 5 6 v2f o; float4 pos = mul(UNITY_MATRIX_MV, v.vertex); float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); normal.z = -0.5 ; pos = pos + float4(normalize (normal), 0 ) * _Outline; o.pos = mul(UNITY_MATRIX_P, pos);
使用模版进行描边
之前我们都是渲染背面然后再进行正面的渲染。依靠模版,我们可以都渲染正面。但是这里还是有坑点的。我们先说明一下,我们现在有两个Pass一个是描边的Pass,一个是正常渲染的Pass。因为我们是都是渲染正面且描边的Pass会进行顶点扩展。如果描边的Pass还是在正常渲染的Pass前渲染,那么正常渲染的Pass就会因为通过不了深度检测而无法覆盖描边。所以这里描边要不进行深度写入。Shader(URP)如下:
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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 Shader "OutLine/OutLineStencil" { Properties { _MainTex ("Texture", 2D) = "white" {} _Width ("Width",Float) = 0 _OutLineColor ("OutLineColor",Color) = (0,0,0,1) } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { Tags{"LightMode"="OutLine"} Stencil { Ref 0 Comp Always Pass Replace } ZWrite Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct appdata { float4 vertex : POSITION; float4 normal : NORMAL; }; struct v2f { float4 vertex : SV_POSITION; }; CBUFFER_START(UnityPerMaterial) float _Width; float4 _OutLineColor; CBUFFER_END v2f vert (appdata v) { v2f o; float3 normal = TransformWorldToHClipDir(TransformObjectToWorldNormal(v.normal),true); o.vertex = TransformObjectToHClip(v.vertex.xyz); o.vertex.xy += normal.xy / _ScreenParams.xy * o.vertex.w * _Width * 2; return o; } half4 frag (v2f i) : SV_Target { return _OutLineColor; } ENDHLSL } Pass { Tags{"LightMode"="UniversalForward"} Stencil { Ref 1 Comp Greater Pass Replace } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; CBUFFER_START(UnityPerMaterial) float4 _MainTex_ST; CBUFFER_END v2f vert (appdata v) { v2f o; o.vertex = TransformObjectToHClip(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } half4 frag (v2f i) : SV_Target { half4 col = tex2D(_MainTex, i.uv); return col; } ENDHLSL } } }
当然如果不进行深度写入也会出现些麻烦。所以我们可以把描边的渲染放到正常渲染的后面,描边的Pass(URP)改为如下:
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 Pass { Tags{"LightMode"="OutLine"} Stencil { Ref 0 Comp Equal Pass Replace } //ZWrite Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct appdata { float4 vertex : POSITION; float4 normal : NORMAL; }; struct v2f { float4 vertex : SV_POSITION; }; CBUFFER_START(UnityPerMaterial) float _Width; float4 _OutLineColor; CBUFFER_END v2f vert (appdata v) { v2f o; float3 normal = TransformWorldToHClipDir(TransformObjectToWorldNormal(v.normal)true); o.vertex = TransformObjectToHClip(v.vertex.xyz); o.vertex.xy += normal.xy / _ScreenParams.xy * o.vertex.w * _Width * 2; return o; } half4 frag (v2f i) : SV_Target { return _OutLineColor; } ENDHLSL }
内置渲染管线中,我们只要调整好对应的Queue
标签就好了。而URP中,我们需要去设置里面修改我们对应的LightMode
渲染队列值(其实我们也可以在设置中进行模版的设置,但是这里我觉得直接写出来更好)。
通过屏幕后处理进行描边
URP的后处理比较麻烦,特别是在创建Pass的时候报错或是没有事先添加Volume
组件的值。我都建议你在确定配置好后,删除并再次添加你要的Render Feature
。
用模糊效果来描边
这种方法进行描边我们需要经历三个步骤:渲染出指定物体的轮廓
-> 进行模糊处理 -> 进行描边处理。我在网络上找到的文章 中有关于其在内置渲染管线中的实现。所以我这里就只给出在URP中的实现了。事先说明,我在URP上的实现确实有着奇怪的地方。这是因为我自己对URP后处理的实现还不太熟悉,虽然我花了一定的时间去学习,但是网上的资料还是太少了。因此下面的代码我只能说实现了描边的效果,但是还有很多问题,而且麻烦。
首先我们先创建一个新的相机(这里我们将这个摄像机称为描边相机,最好直接从主摄像机中复制而来,这样会剩下很多设置),然后移除一下相机上的Audio Listener
。你可以将相机的Render Type
设定Base
,也可以设定为Overlay
。我个人在操作的时候发现虽然Base
和Overlay
都可以实现描边,但是在编辑器下Overlay
会偶尔出现描边消失的情况,而在运行时Overlay
在镜头移动的时候会出现描边和物体分离的情况。如果你设置为Base
,请记得让主摄像机的渲染层级高于描边相机。我们创建一个Layer
取名为OutLine
。我们再创建一个Tag
取名为OutLine
。新相机设定为只渲染Layer
为OutLine
的物体,其Tag
设定为OutLine
。描边摄像机的Environment
参数下的Background Type
设定为Solid Color
。其颜色的Background
参数设定为你想要的值。我这里是设定为纯黑。而描边相机的其他参数就可以和主摄像机设定一样就好了。我们创建一个材质球(我命名为PreOutLine),其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 Shader "Unlit/PostOutLineObjectFillColor" { Properties { _Color ("Color",Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct appdata { float4 vertex : POSITION; }; struct v2f { float4 vertex : SV_POSITION; }; CBUFFER_START(UnityPerMaterial) float4 _Color; CBUFFER_END v2f vert (appdata v) { v2f o; o.vertex = TransformObjectToHClip(v.vertex.xyz); return o; } half4 frag (v2f i) : SV_Target { return _Color; } ENDHLSL } } }
上面的Shader只是简单的将要描边的物体设定为我们想要的颜色,这里必须和描边摄像机中设定的背景颜色不一样。我本来不想设定颜色修改的,但是我考虑到可能有需求需要才加了颜色设定。我个人在使用的时候就是用纯白色。接下来我们在项目使用的URP设置中添加Render Feather
。设定如下图所示:
<img src = "\images\Unity下的描边处理\图9.png" style="margin:auto;"></img>
这个Render Feather
的作用就是将摄像机中所有的Layer
为OutLine
的物体改为另外一个材质渲染,官方文档 有对应的例子。因为是用URP自带的Render Objects
,所以其本身会对所有的摄像机起作用。因此我们要合理的规划好其作用的时机,我个人操作下来是认为BeforRenderingOpaques
是最好的时机了。此时我们顺便设置一下URP配置,我们将URP的深度图选择勾上。
接下来,我们就要将描边相机所看到的画面保存到一个Render Texture
中,并使用模糊算法对Render Texture
进行处理。我个人使用的是5阶的高斯模糊,其算法是来自《Unity
Shader入门精要》。具体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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 Shader "Test/PostOutLineBlur" { Properties { _MainTex ("Texture", 2D) = "white" {} _BlurSize ("BlurSize", Float) = 1 } SubShader { Tags { "RenderType"="Opaque" } //ZTest Always Cull Off ZWrite Off HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv[5] : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; CBUFFER_START(UnityPerMaterial) float _BlurSize; half4 _MainTex_TexelSize; CBUFFER_END v2f vertBlurVertical (appdata v) { v2f o; o.vertex = TransformObjectToHClip(v.vertex.xyz); o.uv[0] = v.uv; o.uv[1] = v.uv + float2(0,_MainTex_TexelSize.y * 1) * _BlurSize; o.uv[2] = v.uv - float2(0,_MainTex_TexelSize.y * 1) * _BlurSize; o.uv[3] = v.uv + float2(0,_MainTex_TexelSize.y * 2) * _BlurSize; o.uv[4] = v.uv - float2(0,_MainTex_TexelSize.y * 2) * _BlurSize; return o; } v2f vertBlurHorizontals (appdata v) { v2f o; o.vertex = TransformObjectToHClip(v.vertex.xyz); o.uv[0] = v.uv; o.uv[1] = v.uv + float2(_MainTex_TexelSize.x * 1,0) * _BlurSize; o.uv[2] = v.uv - float2(_MainTex_TexelSize.x * 1,0) * _BlurSize; o.uv[3] = v.uv + float2(_MainTex_TexelSize.x * 2,0) * _BlurSize; o.uv[4] = v.uv - float2(_MainTex_TexelSize.x * 2,0) * _BlurSize; return o; } half4 frag (v2f i) : SV_Target { float weight[3] = {0.4026,0.2442,0.0545}; half3 sum = tex2D(_MainTex,i.uv[0]).rgb * weight[0]; for(int j = 1; j < 3; j++) { sum += tex2D(_MainTex,i.uv[2 * j - 1]).rgb * weight[j]; sum += tex2D(_MainTex,i.uv[j * 2]).rgb * weight[j]; } return half4(sum,1); } ENDHLSL Pass { NAME "GaussianBlur vertBlurVertical" HLSLPROGRAM #pragma vertex vertBlurVertical #pragma fragment frag ENDHLSL } Pass { NAME "GaussianBlur vertBlurHorizontals" HLSLPROGRAM #pragma vertex vertBlurHorizontals #pragma fragment frag ENDHLSL } } }
为了后面一些效果的实现,我们需要对描边相机的深度图。但是我自己在URP上复制深度图的时候发现它不能简单使用下面的代码进行复制:
1 cmd.Blit(renderer.cameraDepthTargetHandle, depthRT);
在Unity自带的帧分析中,我发现无论怎么样这段代码都是将一个纯黑的图片赋值给我们想要保存的RT(Render
Texture)中。但实际上Unity仍然是存有一个正确的深度图,因此我自己写了一个Shader来复制深度图的信息。其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 Shader "Test/DepthCopy" { SubShader { Tags { "RenderType"="Opaque" } Cull Off ZWrite Off Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex = TransformObjectToHClip(v.vertex.xyz); o.uv = v.uv; return o; } half4 frag (v2f i) : SV_Target { return SampleSceneDepth(i.uv); } ENDHLSL } } }
当然这仍然可能是由于我自己了解不深而产生的没必要操作,如果您有更简便的方法,麻烦您能来告知我一下。
有了这些Shader之后,我们就可以来做模糊处理。但我们毕竟是使用屏幕后处理的方式来进行描边。那么我们还是走一下URP的后处理方法,使用一个脚本来控制后处理的数据。如果你对此了解不多,你可以看一下Unity官方
在B站上的文章 来了解一下。控制后处理的脚本如下所示:
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 using UnityEngine;using UnityEngine.Rendering;using UnityEngine.Rendering.Universal;namespace Test { public class OutLinePostSetting : VolumeComponent , IPostProcessComponent { [Range(0,100),Tooltip("越大描边大小越大" ) ] public FloatParameter blurSize = new FloatParameter(1 ); [Tooltip("描边的颜色" ) ] public ColorParameter outLineColor = new ColorParameter(Color.white); public bool IsActive () { return blurSize.value > 0 ; } public bool IsTileCompatible () { return false ; } } }
然后我们将这个脚本加入到Volume
组件中(在2022.3.8f1c1中,这个组件会在一开始生成场景的时候就有。其挂载在Global Volume
的物体下面)。我们要找到Volume
组件,点击其Add Override
,最后我们直接搜索我们的代码名并加入。接下来我们要创建一个自定义的Render Feature
,其实就是写代码。首先我们先定义一下我们这个Render Feature
所需要的信息(完整的Render Feature
代码在后面):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 [Serializable ] class PostOutLineFlagSetting { public RenderPassEvent flagRenderEvent = RenderPassEvent.AfterRenderingTransparents; public FilterSettings filterSettings; [HideInInspector ] public string postOutlineTextureName = "_PostOutLinePost" ; [HideInInspector ] public string postOutLineDepthTexName = "_PostOutLinePostDepth" ; public bool startProcess = true ; public bool needDepth = true ; }
接下来就是真正执行的代码:
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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 class PostOutLineFlagRenderPass : ScriptableRenderPass { private PostOutLineFlagSetting setting; private RTHandle postOutLineRTHandle; private RTHandle postOutLineDepthRTHandle; private int _postOutLinePostPropertyId; private int _postOutLineDepthPropertyId; private int targetTmpId; private Material depthCopyMat; private Material processMat; private bool ready; public bool Ready { get { return ready; } } public RTHandle PostOutLineRTHandle { get { return postOutLineRTHandle; } } public PostOutLineFlagRenderPass (PostOutLineFlagSetting postOutLineSetting ) { ready = false ; var shader = Shader.Find("Test/DepthCopy" ); if (shader == null ) { Debug.LogError("着色器Test/DepthCopy未找到" ); return ; } depthCopyMat = CoreUtils.CreateEngineMaterial(shader); shader = Shader.Find("Test/PostOutLineBlur" ); if (shader == null ) { Debug.LogError("着色器Test/PostOutLineBlur未找到" ); return ; } processMat = CoreUtils.CreateEngineMaterial(shader); renderPassEvent = postOutLineSetting.flagRenderEvent; setting = postOutLineSetting; _postOutLinePostPropertyId = Shader.PropertyToID(postOutLineSetting.postOutlineTextureName); postOutLineRTHandle = RTHandles.Alloc(postOutLineSetting.postOutlineTextureName, postOutLineSetting.postOutlineTextureName); _postOutLineDepthPropertyId = Shader.PropertyToID(postOutLineSetting.postOutLineDepthTexName); postOutLineDepthRTHandle = RTHandles.Alloc(postOutLineSetting.postOutLineDepthTexName, postOutLineSetting.postOutLineDepthTexName); targetTmpId = Shader.PropertyToID("OutLineFlagTmp" ); ready = true ; } public override void Configure (CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor ) { cmd.GetTemporaryRT(_postOutLinePostPropertyId, cameraTextureDescriptor); cmd.SetGlobalTexture(setting.postOutlineTextureName, postOutLineRTHandle.nameID); cmd.GetTemporaryRT(_postOutLineDepthPropertyId, cameraTextureDescriptor); cmd.SetGlobalTexture(setting.postOutLineDepthTexName, postOutLineDepthRTHandle.nameID); cmd.GetTemporaryRT(targetTmpId, cameraTextureDescriptor); } public override void Execute (ScriptableRenderContext context, ref RenderingData renderingData ) { OutLinePostSetting outLinePostSetting = VolumeManager.instance.stack.GetComponent<OutLinePostSetting>(); if (outLinePostSetting != null && outLinePostSetting.IsActive()) { var cmd = CommandBufferPool.Get(nameof (PostOutLineFlagRenderPass)); cmd.Clear(); var renderer = renderingData.cameraData.renderer; if (renderer.cameraColorTargetHandle.rt != null ) { cmd.Blit(renderer.cameraColorTargetHandle, _postOutLinePostPropertyId); processMat.SetFloat("_BlurSize" , outLinePostSetting.blurSize.value ); cmd.SetGlobalTexture("_MainTex" , _postOutLinePostPropertyId); cmd.Blit(_postOutLinePostPropertyId, targetTmpId, processMat, 0 ); cmd.SetGlobalTexture("_MainTex" , targetTmpId); cmd.Blit(targetTmpId, _postOutLinePostPropertyId, processMat, 1 ); } if (renderer.cameraDepthTargetHandle.rt != null ) { cmd.Blit(renderer.cameraColorTargetHandle, targetTmpId, depthCopyMat); cmd.Blit(targetTmpId, postOutLineDepthRTHandle); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } public override void FrameCleanup (CommandBuffer cmd ) { cmd.ReleaseTemporaryRT(_postOutLinePostPropertyId); cmd.ReleaseTemporaryRT(_postOutLineDepthPropertyId); cmd.ReleaseTemporaryRT(targetTmpId); } }
这里我必须要说明一下代码中一些奇奇怪怪的代码。因为我不知道怎么根据给定的id获取到Render Texture
,所以我使用了cmd.SetGlobalTexture
来对processMat
中的_MainTex
进行赋值。虽然cmd
中的确存在SetGlobalFloat
这个函数,但是我实际使用下面发现根本用。所以我只能使用processMat.SetFloat
。在复制深度图的时候,你会发现我是先给了targetTmpId
然后再给postOutLineDepthRTHandle
。这里有些弯弯绕绕,但是我个人在实验的时候发现只有这样深度图才是对的。否则在后面进行描边处理的时候,postOutLineDepthRTHandle
只会是一片黑。这里我必须仍要强调一点:我不得不承认我自己对于后处理的知识还是少了,所以代码上会存在奇怪的地方,但是效果是实现了。最后,我们只要考虑和主摄像机混合的实现了。混合的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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 Shader "Test/PostOutLine" { Properties { _MainTex ("Texture", 2D) = "white" {} _OutLineColor ("OutLineColor",Color) = (1,1,1,1) _BlurSize("BlurSize",Float) = 5 _LimitValue ("_LimitValue",Float) = 0.001 } SubShader { Tags { "RenderType"="Opaque" } Cull Off ZWrite Off Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma shader_feature_local _OUTLINEDEPTHMAP #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; sampler2D _PostOutLinePost; sampler2D _PostOutLinePostDepth; CBUFFER_START(UnityPerMaterial) half4 _OutLineColor; half4 _PostOutLinePostDepth_TexelSize; half _LimitValue; float _BlurSize; CBUFFER_END v2f vert (appdata v) { v2f o; o.vertex = TransformObjectToHClip(v.vertex.xyz); o.uv = v.uv; return o; } half4 frag (v2f i) : SV_Target { half4 mainCol = tex2D(_MainTex, i.uv); half postVal = tex2D(_PostOutLinePost, i.uv).r; postVal = step(postVal,_LimitValue); float orightDep = step(0.001,1 - tex2D(_PostOutLinePostDepth, i.uv).r); float dep = 1; #ifdef _OUTLINEDEPTHMAP dep = 0; half pixelVal = _PostOutLinePostDepth_TexelSize.x; float2 uv = i.uv - float2(2,2) * _BlurSize * pixelVal; // 根据模糊处理来得到描边该有的深度值 for(int j = 0;j < 5; ++j) { for(int k = 0;k < 5; ++k) { dep = max((1 - tex2D(_PostOutLinePostDepth, uv + float2(pixelVal * j,pixelVal * k) * _BlurSize).r),dep); } } dep = 1 - dep; float depMain = SampleSceneDepth(i.uv); dep = depMain - dep; dep = step(0.005f,dep); #endif half4 blendColr = lerp(mainCol,_OutLineColor,(1 - postVal - orightDep) * dep); return blendColr; } ENDHLSL } } }
在我参考的文章里面,作者其实存储了两张图片。一张是未进行模糊的图片,一张是模糊过的图片。两张图片进行相减就得到了描边的图片。这里我将未进行模糊的图片代替为深度图,毕竟描边相机只能看到需要描边的图体。那么描边相机的深度图只要经过一些变换,那就是未进行模糊的图片。这里我的变化规则是让有深度的地方变为1。即代码中的
1 float orightDep = step(0.001,1 - tex2D(_PostOutLinePostDepth, i.uv).r);
你或许觉得这步多此一举,我直接和作者一样直接存储一张未进行模糊的图片不就好了。我一开始的确是这样的做的,但是我发现这里存在一个问题。那就是当有一个物体遮挡描边物体时,其描边仍然存在。如下图所示:
<img src = "\images\Unity下的描边处理\图10.png" style="margin:auto;"></img>
之所以出现这样的原因,其实就是我们这个是一个后处理的做法,本身就是在已经渲染图片上盖颜色。所以如果不考虑其他物体的深度,那么就会出现这样的问题。而我的做法是在深度图中加入模糊操作,但是因为我们的确需要一个原先深度图来保证描边区域的正确性。所以我这里是原地计算模糊结果,然后将计算后的深度于主摄像机的深度值进行相减。最终判断出是否要渲染描边,这里我用了0.005的数值。这个数值其实没什么讲究的,只是我在测试中发现这个数值的效果不错,你也可以将这个数值定义为可修改的。开启深度测试后的效果如下:
<img src = "\images\Unity下的描边处理\图11.png" style="margin:auto;"></img>
接下来我们就要将这个Shader的效果写入我们自定义的Render Feature
中。当这里我为了减少篇幅,我就直接放完整的代码了。
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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 using System;using UnityEngine;using UnityEngine.Rendering;using UnityEngine.Rendering.Universal;using static UnityEngine.Experimental.Rendering.Universal.RenderObjects;namespace Test { public class PostOutLine : ScriptableRendererFeature { [Serializable ] class PostOutLineFlagSetting { public RenderPassEvent flagRenderEvent = RenderPassEvent.AfterRenderingTransparents; public FilterSettings filterSettings; [HideInInspector ] public string postOutlineTextureName = "_PostOutLinePost" ; [HideInInspector ] public string postOutLineDepthTexName = "_PostOutLinePostDepth" ; public bool startProcess = true ; public bool needDepth = true ; } class PostOutLineFlagRenderPass : ScriptableRenderPass { private PostOutLineFlagSetting setting; private RTHandle postOutLineRTHandle; private RTHandle postOutLineDepthRTHandle; private int _postOutLinePostPropertyId; private int _postOutLineDepthPropertyId; private int targetTmpId; private Material depthCopyMat; private Material processMat; private bool ready; public bool Ready { get { return ready; } } public RTHandle PostOutLineRTHandle { get { return postOutLineRTHandle; } } public PostOutLineFlagRenderPass (PostOutLineFlagSetting postOutLineSetting ) { ready = false ; var shader = Shader.Find("Test/DepthCopy" ); if (shader == null ) { Debug.LogError("着色器Test/DepthCopy未找到" ); return ; } depthCopyMat = CoreUtils.CreateEngineMaterial(shader); shader = Shader.Find("Test/PostOutLineBlur" ); if (shader == null ) { Debug.LogError("着色器Test/PostOutLineBlur未找到" ); return ; } processMat = CoreUtils.CreateEngineMaterial(shader); renderPassEvent = postOutLineSetting.flagRenderEvent; setting = postOutLineSetting; _postOutLinePostPropertyId = Shader.PropertyToID(postOutLineSetting.postOutlineTextureName); postOutLineRTHandle = RTHandles.Alloc(postOutLineSetting.postOutlineTextureName, postOutLineSetting.postOutlineTextureName); _postOutLineDepthPropertyId = Shader.PropertyToID(postOutLineSetting.postOutLineDepthTexName); postOutLineDepthRTHandle = RTHandles.Alloc(postOutLineSetting.postOutLineDepthTexName, postOutLineSetting.postOutLineDepthTexName); targetTmpId = Shader.PropertyToID("OutLineFlagTmp" ); ready = true ; } public override void Configure (CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor ) { cmd.GetTemporaryRT(_postOutLinePostPropertyId, cameraTextureDescriptor); cmd.SetGlobalTexture(setting.postOutlineTextureName, postOutLineRTHandle.nameID); cmd.GetTemporaryRT(_postOutLineDepthPropertyId, cameraTextureDescriptor); cmd.SetGlobalTexture(setting.postOutLineDepthTexName, postOutLineDepthRTHandle.nameID); cmd.GetTemporaryRT(targetTmpId, cameraTextureDescriptor); } public override void Execute (ScriptableRenderContext context, ref RenderingData renderingData ) { OutLinePostSetting outLinePostSetting = VolumeManager.instance.stack.GetComponent<OutLinePostSetting>(); if (outLinePostSetting != null && outLinePostSetting.IsActive()) { var cmd = CommandBufferPool.Get(nameof (PostOutLineFlagRenderPass)); cmd.Clear(); var renderer = renderingData.cameraData.renderer; if (renderer.cameraColorTargetHandle.rt != null ) { cmd.Blit(renderer.cameraColorTargetHandle, _postOutLinePostPropertyId); processMat.SetFloat("_BlurSize" , outLinePostSetting.blurSize.value ); cmd.SetGlobalTexture("_MainTex" , _postOutLinePostPropertyId); cmd.Blit(_postOutLinePostPropertyId, targetTmpId, processMat, 0 ); cmd.SetGlobalTexture("_MainTex" , targetTmpId); cmd.Blit(targetTmpId, _postOutLinePostPropertyId, processMat, 1 ); } if (renderer.cameraDepthTargetHandle.rt != null ) { cmd.Blit(renderer.cameraColorTargetHandle, targetTmpId, depthCopyMat); cmd.Blit(targetTmpId, postOutLineDepthRTHandle); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } public override void FrameCleanup (CommandBuffer cmd ) { cmd.ReleaseTemporaryRT(_postOutLinePostPropertyId); cmd.ReleaseTemporaryRT(_postOutLineDepthPropertyId); cmd.ReleaseTemporaryRT(targetTmpId); } } class PostOutLineBlenderRenderPass : ScriptableRenderPass { private Material material; private bool ready; private int targetTmpId; private bool depthVal; private LocalKeyword useDepth; private RenderTargetIdentifier _postOutLineBlenderPropertyId; public bool Ready { get { return ready; } } public PostOutLineBlenderRenderPass () { ready = false ; depthVal = false ; var shader = Shader.Find("Test/PostOutLine" ); if (shader == null ) { Debug.LogError("着色器Test/PostOutLine未找到" ); return ; } useDepth = new LocalKeyword(shader, "_OUTLINEDEPTHMAP" ); targetTmpId = Shader.PropertyToID("PostOutLineBlenderTmp" ); material = CoreUtils.CreateEngineMaterial(shader); ready = true ; } public override void Execute (ScriptableRenderContext context, ref RenderingData renderingData ) { OutLinePostSetting outLinePostSetting = VolumeManager.instance.stack.GetComponent<OutLinePostSetting>(); if (outLinePostSetting != null && renderingData.cameraData.renderer.cameraColorTargetHandle.rt != null ) { var cmd = CommandBufferPool.Get(nameof (PostOutLineBlenderRenderPass)); cmd.Clear(); _postOutLineBlenderPropertyId = renderingData.cameraData.renderer.cameraColorTargetHandle; material.SetFloat("_BlurSize" , outLinePostSetting.blurSize.value ); material.SetColor("_OutLineColor" , outLinePostSetting.outLineColor.value ); var camera = renderingData.cameraData.camera; cmd.GetTemporaryRT(targetTmpId, camera.scaledPixelWidth, camera.scaledPixelHeight, 0 , FilterMode.Point, RenderTextureFormat.Default); cmd.Blit(_postOutLineBlenderPropertyId, targetTmpId); cmd.SetGlobalTexture("_MainTex" , _postOutLineBlenderPropertyId); cmd.Blit(targetTmpId, _postOutLineBlenderPropertyId, material); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } public override void FrameCleanup (CommandBuffer cmd ) { cmd.ReleaseTemporaryRT(targetTmpId); } public void SetDepthVal (bool depthVal ) { if (this .depthVal != depthVal) { material.SetKeyword(useDepth, depthVal); this .depthVal = depthVal; } } } PostOutLineFlagRenderPass flagPass; PostOutLineBlenderRenderPass blenderPass; [SerializeField ] PostOutLineFlagSetting postOutLineSetting = new PostOutLineFlagSetting(); public override void Create () { flagPass = new PostOutLineFlagRenderPass(postOutLineSetting); blenderPass = new PostOutLineBlenderRenderPass(); blenderPass.renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing; } public override void AddRenderPasses (ScriptableRenderer renderer, ref RenderingData renderingData ) { if (flagPass.Ready && postOutLineSetting.startProcess) { if (renderingData.cameraData.camera.gameObject.tag.Equals("OutLine" )) { renderer.EnqueuePass(flagPass); } else if (renderingData.cameraData.camera.gameObject.tag.Equals("MainCamera" )) { if (blenderPass.Ready) { blenderPass.SetDepthVal(postOutLineSetting.needDepth); renderer.EnqueuePass(blenderPass); } } } } } }
对于这个模糊的后处理描边,我个人先说他的缺点吧。首先你要多配置一个描边相机,且当主相机变化的时候描边相机也要变化。这里变化不仅仅是位置和旋转变换,一些参数上的变化,描边相机也要一起变。而相机本身在性能上开销较大了。更不用说,我们实现这个效果时,我们还要多开两个Render Texture
和一些临时的Render Texture
。而在效果方面,他其实十分依赖模糊算法。我自己就发现如果将我的模糊大小即_BlurSize
开得越大。物体就越不像描边,如下图所示:
<img src = "\images\Unity下的描边处理\图12.png" style="margin:auto;"></img>
在一些极端情况下,它甚至是这样的。
<img src = "\images\Unity下的描边处理\图13.png" style="margin:auto;"></img>
这自然是和我选择的模糊算法有关,但是我认为这本身也是其缺陷的一环。下面这个现象说缺点不太对,我觉得应该算是特性吧。当两个需要描边的物体在视角中重合时,他们重合的部分描边存在缺失
<img src = "\images\Unity下的描边处理\图14.png" style="margin:auto;"></img>
图中的两个正方体是一前一后摆放的。图中绿色圈圈起的地方,有些描边的实现中也会存在描边。但我们的确有可能需要这样的效果。比如选择一群兵的时候,这样的描边会更好看。但是我其实可以使用模版测试以更简单的方式来实现这样的功能。这里还有一个问题是描边的长度不可控,不过这个可能是我个人实现问题。整体而言,我觉得这个方法还是太复杂了。
我能想到这个方法的优点在于,他的描边形状仅仅和物体形状相关。这样美术就不需要考虑法线是否圆滑和其他的问题。
用边缘检测来描边
我知道的边缘检测有两种方式。一种是直接对屏幕图像进行处理,通过边缘检测算子对图像进行卷积操作,判断出哪些是边缘。这种方法的优势就是简单,劣势就是他会显示出你不想要的边缘。另外一种是深度图+深度法线的做法,原理和第一种差不多,区别在于边界的判断上,第一种是依据颜色间的差值进行判断,第二种则是按照深度值的差值和法线值的差值来判断的。我认为原理大概就这样,但是在边缘检测算子上的选择和边界判断的条件,大家都各有不同。因为URP的深度法线图获取较为麻烦,我想懒一下。所以这里我使用的是第一种方式,在颜色差值方面,我使用的是颜色亮度的差值。这两种边缘检测的方式在《Unity
Shader入门精要》中都有提及,大家去网上搜索也大都能找到对应的内置渲染管线的实现和更加具体的原理描述,因此我这里只写明了URP下的处理。
整体的流程和模糊描边的流程一致。首先是描边的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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 Shader "Test/EdgeOutLine" { Properties { _MainTex ("Texture", 2D) = "white" {} _EdgeColor ("EdgeColor", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" } Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { half2 uv[9] : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; CBUFFER_START(UnityPerMaterial) half4 _MainTex_TexelSize; half4 _EdgeColor; CBUFFER_END v2f vert (appdata v) { v2f o; o.vertex = TransformObjectToHClip(v.vertex.xyz); o.uv[0] = v.uv + _MainTex_TexelSize.xy * half2(-1,-1); o.uv[1] = v.uv + _MainTex_TexelSize.xy * half2(0,-1); o.uv[2] = v.uv + _MainTex_TexelSize.xy * half2(1,-1); o.uv[3] = v.uv + _MainTex_TexelSize.xy * half2(-1,0); o.uv[4] = v.uv; o.uv[5] = v.uv + _MainTex_TexelSize.xy * half2(1,0); o.uv[6] = v.uv + _MainTex_TexelSize.xy * half2(-1,1); o.uv[7] = v.uv + _MainTex_TexelSize.xy * half2(0,1); o.uv[8] = v.uv + _MainTex_TexelSize.xy * half2(1,1); return o; } half luminance(half4 color) { return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b; } half Sobel(v2f i) { half Gx[9] = {-1, 0, 1, -2, 0, 2, -1, 0, 1}; half Gy[9] = {-1, -2, -1, 0, 0, 0, 1, 2, 1}; half texColor; half edgeX = 0; half edgeY = 0; for (int j = 0; j < 9; j++) { texColor = luminance(tex2D(_MainTex, i.uv[j])); edgeX += texColor * Gx[j]; edgeY += texColor * Gy[j]; } half edge = 1 - abs(edgeX) - abs(edgeY); return saturate(edge); } half4 frag (v2f i) : SV_Target { half edge = Sobel(i); half4 withEdgeColor = lerp(_EdgeColor,tex2D(_MainTex,i.uv[4]),edge); return withEdgeColor; } ENDHLSL } } }
接下来我们创建名为EdgeOutLineSetting
的脚本来做一些后处理的设置。
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 using UnityEngine;using UnityEngine.Rendering;using UnityEngine.Rendering.Universal;namespace Test { public class EdgeOutLineSetting : VolumeComponent , IPostProcessComponent { [SerializeField ] [Tooltip("是否启动描边" ) ] private BoolParameter activeSelf = new BoolParameter(false ); [Tooltip("描边的颜色" ) ] public ColorParameter outLineColor = new ColorParameter(Color.white); public bool IsActive () { return this .activeSelf.value ; } public bool IsTileCompatible () { return false ; } } }
之后我们添加脚本到场景中的Volume
组件下。最后我们创建一个Render Feature
(其实就是创建脚本)。
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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 using UnityEngine;using UnityEngine.Rendering;using UnityEngine.Rendering.Universal;namespace Test { public class EdgePostOutLine : ScriptableRendererFeature { class EdgePostOutLineRenderPass : ScriptableRenderPass { private Material material; private int edgePostOutLineId; private bool ready; public bool Ready { get { return ready; } } public EdgePostOutLineRenderPass () { ready = false ; var shader = Shader.Find("Test/EdgeOutLine" ); if (shader == null ) { Debug.LogError("着色器Test/EdgeOutLine未找到" ); return ; } material = CoreUtils.CreateEngineMaterial(shader); edgePostOutLineId = Shader.PropertyToID("_EdgePostOutLineTex" ); ready = true ; } public override void Configure (CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor ) { cmd.GetTemporaryRT(edgePostOutLineId, cameraTextureDescriptor); } public override void Execute (ScriptableRenderContext context, ref RenderingData renderingData ) { var cmd = CommandBufferPool.Get(nameof (EdgePostOutLineRenderPass)); cmd.Clear(); var renderer = renderingData.cameraData.renderer; if (renderer.cameraColorTargetHandle.rt != null ) { cmd.Blit(renderer.cameraColorTargetHandle, edgePostOutLineId); cmd.SetGlobalTexture("_MainTex" , edgePostOutLineId); cmd.Blit(edgePostOutLineId, renderer.cameraColorTargetHandle, material, 0 ); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public override void OnCameraCleanup (CommandBuffer cmd ) { cmd.ReleaseTemporaryRT(edgePostOutLineId); } public void SetEdgeColor (Color edgeCol ) { material.SetColor("_EdgeColor" , edgeCol); } } private EdgePostOutLineRenderPass m_ScriptablePass; public RenderPassEvent passEvent; public override void Create () { m_ScriptablePass = new EdgePostOutLineRenderPass(); m_ScriptablePass.renderPassEvent = passEvent; } public override void AddRenderPasses (ScriptableRenderer renderer, ref RenderingData renderingData ) { EdgeOutLineSetting edgePostOutLine = VolumeManager.instance.stack.GetComponent<EdgeOutLineSetting>(); if (edgePostOutLine.IsActive() && edgePostOutLine.active) { m_ScriptablePass.SetEdgeColor(edgePostOutLine.outLineColor.value ); renderer.EnqueuePass(m_ScriptablePass); } } } }
然后我们找到URP的设置将我们的Render Feature
添加上去,并设置好合适的触发时间。最终表现效果如下所示:
<img src = "\images\Unity下的描边处理\图15.png" style="margin:auto;"></img>
这个效果确实不太好,主要还是因为单纯用颜色来做边缘检测。这样也导致了描边的大小不可定。其实用另外一种方式就可以尽量避免这样的情况,且描边大小可控。但是URP上获取深度法线还是麻烦了。
参考文章:
下面是我写文章的时候在网络上找到的关于描边的文章。这些文章中的一部分实现我并没有去做,因为我个人觉得很麻烦。如果你感兴趣的话可以点击这些文章链接看看。
https://ameye.dev/notes/rendering-outlines/
https://blog.csdn.net/sinat_25415095/article/details/124053368
https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@14.0/manual/renderer-features/how-to-custom-effect-render-objects.html
https://1keven1.github.io/2021/04/01/%E3%80%90Unity%E3%80%91URP%E8%87%AA%E5%AE%9A%E4%B9%89%E5%90%8E%E5%A4%84%E7%90%86%E6%95%88%E6%9E%9C/
https://www.bilibili.com/read/cv11343490/
https://blog.csdn.net/u013066730/article/details/123112159
闲言碎语
本来在2024年2月份时,我就开始写这篇文章了。本来刚做完一个项目的人还不算忙,所以我才有时间去找资料写文章。前面本来还好,可是后面我开始了新的项目没那么多时间了。不过这只是其中一个原因。毕竟你看我博文其实在2月份到7月份间,我还是有更新文章的,甚至有些是长文。主要让我不更新原因是在后处理实现描边上,我遇到了些困难。我也承认在后处理这个模块上,我的确有些敷衍。这并不是因为我看不起这个方法,而是我在后处理描边上消耗了太多的热情和精力。不全或是老旧的教程都让我感到非常的难受,加上Unity现在频繁修改API,我也不确定自己现在写出来的方法能到Unity的哪个版本。所以我拖延了许久,只是最近我很闲且在看《Real-Time
Rendering 4th
Edition》。突然间我想起了这个未完成的文章,所以我就来写了。其实我是因为我理解《Real-Time
Rendering 4th
Edition》起来过于困难,可我又不想单纯闲下来,所以我选择去攻克这些难题。不过我也不是没有收获,至少当我实现了这些后处理效果的时候,我还是很开心的。终于这篇文章结束时,我又收到了新的项目需求。我又要开始忙起来了。《Real-Time
Rendering 4th Edition》又可以拖一拖了。