Unity3D Shader系列之描边


1 引言

总结下描边效果的实现方式,主要有以下几种:
①法线外拓+ZTest Always
②法线外拓+Cull Front
③法线外拓+ZWrite Off
④法线外拓+模板测试
⑤基于屏幕后处理

2 顶点沿法线外拓方式

法线外拓的原理如下:
基本原理还是很简单的:模型渲染两次,第一次渲染时将模型的顶点沿法线方向外拓,然后绘制描边颜色,第二次渲染按正常的渲染即可。也就是用第二次渲染去覆盖掉第一次的渲染,由于第二次没有法线外拓,所以只会覆盖掉中间的部分,从而实现描边。
这个过程就像画家绘画样,对于同一个位置,用后画的颜色去覆盖掉先画的颜色。
法线外拓实现描边原理
所以这里最主要的问题在于,如何保证第二个Pass一定能覆盖第一个Pass。以下几种方法都可实现,一般方法②和方法③用得多一点:
方法①第二个Pass开启ZTest Always
方法②第一个Pass使用Cull Front
方法③第一个Pass使用ZWrite Off
方法④使用模板测试

2.1 法线外拓+ZTest Always

2.1.1 代码

要保证第二个Pass一定能覆盖掉第一个Pass,最简单的方法就是让深度测试一直通过,即使用ZTest Always。但是使用ZTest Always问题是非常多的,我们下一节再详说。

伪代码如下:

// 先用描边颜色渲染
Pass
{
	...
	// 顶点着色器:顶点沿着法线外拓
	v2f vert (appdata v)
    {
        v2f o;
		v.vertex.xy += normalize(v.normal) * _OutlineWidth;
		o.vertex = UnityObjectToClipPos(v.vertex);
        return o;
    }
	
	// 片元着色器:直接绘制描边颜色
	fixed4 frag (v2f i) : SV_Target
	{
		return _OutlineColor;
	}
}

// 再正常渲染
Pass
{
	// 保证此Pass一定会渲染
	ZTest Always
	// ...
}

完整代码如下:

Shader "LaoWang/Outline_Example01"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
		_OutlineWidth ("Outline width", Range(0.01, 4)) = 0.01
		_OutlineColor ("Outline Color", color) = (1.0, 1.0, 1.0, 1.0)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry"}
        LOD 100

		Pass
		{
			CGPROGRAM

			#pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
				float3 normal : NORMAL;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };

			float _OutlineWidth;
			fixed4 _OutlineColor;

            v2f vert (appdata v)
            {
                v2f o;
				v.vertex.xy += normalize(v.normal) * _OutlineWidth;
				o.vertex = UnityObjectToClipPos(v.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return _OutlineColor;
            }

			ENDCG
		}

        Pass
        {
        	ZTest Always
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
			#include "Lighting.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 color = tex2D(_MainTex, i.uv);
                return fixed4(color.rgb, 1.0);
            }
            ENDCG
        }
    }
}

2.1.2 问题点

由于第二个Pass使用了ZTest Always,会导致两个问题。
①模型自身会穿透自身
但是我们会发现只有部分网格会穿透自身。为什么只有部分网格会穿透呢?我也没弄清楚。
GPU绘制某一模型时,同一模型中的各个三角面的渲染顺序是如何控制的呢?希望知道的同学解答一下,在此谢过。
ZTest Always的问题01
②物体将会永远再最前面
ZTest Always的问题02
所以这种方式基本没人使用。

2.2 法线外拓+Cull Front

2.2.1 代码

原理和上一节类似,只不过不是用ZTest Always来保证第二个Pass覆盖第一个Pass,而是在第一个Pass中使用Cull Front,即第一个Pass只渲染模型的背面,然后让背面向外拓展一下,因为一般背面都在正面的后面(即背面的深度值比正面的深度值大),所以第二个Pass就会覆盖掉中间部分。

Shader "LaoWang/Outline_CullFront"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
		_OutlineWidth ("Outline width", Range(0.01, 4)) = 0.01
		_OutlineColor ("Outline Color", color) = (1.0, 1.0, 1.0, 1.0)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry"}
        LOD 100

		Pass
		{
			Cull Front
			CGPROGRAM

			#pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
				float3 normal : NORMAL;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };

			float _OutlineWidth;
			fixed4 _OutlineColor;

            v2f vert (appdata v)
            {
                v2f o;
				v.vertex.xy += normalize(v.normal) * _OutlineWidth;
				o.vertex = UnityObjectToClipPos(v.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return _OutlineColor;
            }

			ENDCG
		}

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
			#include "Lighting.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 color = tex2D(_MainTex, i.uv);
                return fixed4(color.rgb, 1.0);
            }
            ENDCG
        }
    }
}

效果是这样的。
描边效果
虽然Robot Kyle这个模型使用这种方式效果不好,但是对于其他绝大部分模型还是够用了。
一般的描边就是使用这种方式。

2.2.2 改进点

①无论相机距离物体多远或者观察视角的变化,都让描边的宽度保持等比例。
如图。
各处描边厚度不一样的问题
出现这样问题的原因在于我们是在模型空间对顶点进行外拓的,外拓的距离是一样的。但是由于是透视相机,模型上离相机近的地方描边效果较粗,而远的地方描边效果较细。
解决这个问题的方法是,我们不在模型空间外拓,而在齐次裁剪空间将顶点沿法线方向进行外拓。
而这个方法最大的问题在于,如何才能求到齐次裁剪空间中的法线方向?
顶点从模型空间变换到齐次裁剪空间的变换矩阵是MVP,那法线的变换能否直接使用MVP矩阵呢?答案是不行。法线的变换应该是变换矩阵的逆转置矩阵,即我们这里将使用(MVP)-1T来进行法线变换。
为什么法线变换不能直接使用变换矩阵,而要使用逆转置矩阵呢?主要是为了保证存在非等比缩放时,变换后的法线方向依然是垂直与表面的。如果不存在非等比缩放,即只存在旋转,那么法线的变换是可以直接使用变换矩阵的。(具体描述详见《Unity Shader入门精要》4.7节 法线变换)
法线变换问题
那MVP矩阵是只有旋转吗?不是的。P矩阵即从观察空间到齐次裁剪空间的变换矩阵一定是存在非等比缩放的。所以,我们这里需要用到MVP的逆转置矩阵。
两种相机的P矩阵
回到最初的问题,MVP的逆转置矩阵该怎么求?很遗憾Unity的Shader中并没有直接提供相应的变量,要真正得到这个逆转置矩阵需要从C#端计算然后传递到shader。但其实我们并不需要那么高的精度,近似即可。有两种近似方式。
一是直接使用MVP矩阵来近似。

v2f vert (appdata v)
{
	o.vertex = UnityObjectToClipPos(v.vertex);
	float3 clipNormal = mul((float3x3) UNITY_MATRIX_VP, mul((float3x3) UNITY_MATRIX_M, v.normal));
	o.vertex.xy += normalize(clipNormal).xy * _OutlineWidth;
}

二是使用(MV)的逆转置矩阵* P来近似。为什么使用MV的逆转置矩阵呢,是因为Unity刚好提供了这个变量,UNITY_MATRIX_IT_MV。

v2f vert (appdata v)
{
	o.vertex = UnityObjectToClipPos(v.vertex);
	float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
	float2 clipNormal = mul((float2x2)UNITY_MATRIX_P, viewNormal.xy);
	o.vertex.xy += normalize(clipNormal) * _OutlineWidth;
}

效果对比如下,可以看到上面两种方式的效果其实差不多。
三种外拓方式效果对比

2.3 法线外拓+ZWrite Off

2.3.1 代码

逻辑也很简单,第一个Pass由于关闭了深度写入,那么第二个Pass肯定能够通过深度测试,所以第二个Pass会覆盖掉第一个Pass。

Shader "LaoWang/Outline_ZWriteOff"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
		_OutlineWidth ("Outline width", Range(0.01, 4)) = 0.01
		_OutlineColor ("Outline Color", color) = (1.0, 1.0, 1.0, 1.0)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry"}
        LOD 100

		Pass
		{
			ZWrite Off

			CGPROGRAM

			#pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
				float3 normal : NORMAL;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };

			float _OutlineWidth;
			fixed4 _OutlineColor;

            v2f vert (appdata v)
            {
                v2f o;
				//v.vertex.xy += normalize(v.normal) * _OutlineWidth;
				//o.vertex = UnityObjectToClipPos(v.vertex);

				o.vertex = UnityObjectToClipPos(v.vertex);
				float3 clipNormal = mul((float3x3) UNITY_MATRIX_VP, mul((float3x3) UNITY_MATRIX_M, v.normal));
				o.vertex.xy += normalize(clipNormal).xy * _OutlineWidth;

				//o.vertex = UnityObjectToClipPos(v.vertex);
				//float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
				//float2 clipNormal = mul((float2x2)UNITY_MATRIX_P, viewNormal.xy);
				//o.vertex.xy += normalize(clipNormal) * _OutlineWidth;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return _OutlineColor;
            }

			ENDCG
		}

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
			#include "Lighting.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 color = tex2D(_MainTex, i.uv);
                return fixed4(color.rgb, 1.0);
            }
            ENDCG
        }
    }
}

效果如下,可以看到Robot Kyle这个模型使用这种方式效果是最好的。
ZWrite Off效果对比

2.3.2 问题点

ZWrite Off关闭后会有两个问题。具体如下。
我们在场景中加上一个Ground,新建一个标准的材质球。
标准材质球
然后我们就会发现有地板的部分描边就消失了。
ZWrite Off的问题
我们从Frame Debugger中可以看出地板是最后绘制的。而绘制描边时没有开启深度写入,这就导致地板的深度测试会通过。所以地板的颜色会覆盖掉描边部分的颜色。
ZWrite Off的问题测试
要解决这个问题,其实也很简单,最后渲染我们的模型就行了。用什么方法控制我们的模型最后渲染呢?当然是控制渲染队列啦,这点我们在《Unity3D Shader系列之透视效果XRay》中讲过,就不再多说了。
我们将渲染队列设置为“Geometry+1”,即在所有不透明物体渲染后再渲染我们的模型。
调整渲染队列之后的效果
调整之后,描边效果就正常了。
但是这样调整之后依然还有问题,比如我们再复制一个描边模型,然后一个在前一个在后。此时,我们将会发现两个模型重叠的部分没有描边了。
重叠部分的描边没有了
同样,我们去Frame Debugger中看看原因。
ZWrite Off导致的问题
从上图我们可以看到,是先绘制的前面的物体,再绘制后面的物体,就导致绘制后面物体时将前面物体的描边给覆盖掉了。
要解决这个问题,我们得有一个储备知识:Unity在渲染不透明物体时,如果这两个物体的渲染队列一样(Render Queue的值一样),则按距离摄像机由近到远的顺序依次渲染。在渲染半透明物体时,如果这两个物体的渲染队列一样(Render Queue的值一样),则按距离摄像机由远到近的顺序依次渲染。
为什么要这样做?对于不透明物体,先渲染近的再渲染远的,由于硬件的Early-Z等技术,可以减少Over Draw。对于半透明物体,由于需要关闭深度写入,所以必须先渲染远的再渲染近的,这样才能保证混合后的颜色是正确的。
所以我们这里要想让两个模型重叠的部分也能绘制出描边效果,就得先渲染后面的再渲染前面的,那把渲染队列改为Transparent就可以了。但是这个办法也不是完全能解决问题的,因为我们上面给出的距离摄像机的远近其实很模糊,这个远近到底是取哪个值?是取物体的世界坐标与相机的距离呢还是物体的某个顶点距离相机的距离呢?Unity官方也没给出说明。
改为Transparent后的效果

2.4 法线外拓+模板测试

先正常渲染物体,将模板缓冲区写为1。然后再法线外拓进行描边,当模板缓冲区值为0时绘制描边。
伪代码。

Pass
{
	// 将模板缓冲区写为1
	Stencil
	{
		Ref 1
		Comp Always
		Pass Replace
	}

	// 正常渲染
	...
}

Pass
{
	Stencil
	{
		Ref 0
		Comp Equal
	}
	ZWrite Off

	// 渲染描边
	// 顶点着色器法线外拓
	...
}

完整代码。

Shader "LaoWang/Outline_StencilTest"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
		_OutlineWidth ("Outline width", Range(0.01, 4)) = 0.01
		_OutlineColor ("Outline Color", color) = (1.0, 1.0, 1.0, 1.0)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry+1"}
        LOD 100

		Pass
        {
			Stencil
			{
				Ref 1
				Comp Always
				Pass Replace
			}

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
			#include "Lighting.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 color = tex2D(_MainTex, i.uv);
                return fixed4(color.rgb, 1.0);
            }
            ENDCG
        }

		Pass
		{
			Stencil
			{
				Ref 0
				Comp Equal
			}

			ZWrite Off

			CGPROGRAM

			#pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
				float3 normal : NORMAL;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };

			float _OutlineWidth;
			fixed4 _OutlineColor;

            v2f vert (appdata v)
            {
                v2f o;
				//v.vertex.xy += normalize(v.normal) * _OutlineWidth;
				//o.vertex = UnityObjectToClipPos(v.vertex);

				o.vertex = UnityObjectToClipPos(v.vertex);
				float3 clipNormal = mul((float3x3) UNITY_MATRIX_VP, mul((float3x3) UNITY_MATRIX_M, v.normal));
				o.vertex.xy += normalize(clipNormal).xy * _OutlineWidth;

				//o.vertex = UnityObjectToClipPos(v.vertex);
				//float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
				//float2 clipNormal = mul((float2x2)UNITY_MATRIX_P, viewNormal.xy);
				//o.vertex.xy += normalize(clipNormal) * _OutlineWidth;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return _OutlineColor;
            }

			ENDCG
		}
    }
}

效果如下。
模板测试描边效果
这种方式也会有两个模型重叠部分没有描边的问题,但是由于使用的是模板测试,这个问题是无解的了。

2.5 法线外拓实现描边的问题

使用法线外拓实现描边都存在下图这样的问题,即法线不是连续的时候,描边就会中断。
法线外拓实现描边的问题
要解决这个问题需要写一个工具将顶点的法线平滑一下,并将其保存在顶点的颜色数据中,然后在外拓时使用平滑后的法线来外拓。
可以参考这篇文章,里面实现了法线平滑工具。

3 屏幕后处理的方式

使用屏幕后处理实现描边一般有两种方式,一是使用Unity中Camera的着色器替代技术,二是使用Render Command。

3.1 使用Camera的着色器替代技术

这种方式我没有去具体实现,但是去了解了下,这里总结下大概的思路。

3.1.1 着色器替代技术

什么是Camera的着色器替代技术?
说起来高大上,其实本质的东西并不复杂,就是用相机重新渲染一遍场景,但是本次渲染的过程中,场景中的物体(不一定是全部物体,我们可以用代码控制只渲染某一部分物体)不再使用它自身的Shader进行着色,而是使用特定的Shader(所有要渲染的物体都是用同一个Shader)来着色。
从代码上来说,就是下面Camera类中的两个方法。

public void RenderWithShader(Shader shader, string replacementTag);
public void SetReplacementShader(Shader shader, string replacementTag);

RenderWithShader只有调用时的那一帧有效。
SetReplacementShader是调用之后Camera渲染都一直使用指定的Shader,直到代码主动调用ResetReplacementShader方法。

public void ResetReplacementShader();

说一下两个方法的参数。
第一个参数为相机渲染时将使用的Shader。
第二参数为相机查找的标签,一般指定为RenderType。
举个例子,相信大家一看就明白了。
我们这样调用。

Camera.main.SetReplacementShader(Shader.Find("LaoWang/CameraReplace"), "RenderType");

对应的Shader代码如下:

Shader "LaoWang/CameraReplace"
{
	SubShader
    {
		// 渲染不透明物体时用此Pass替代
        Tags { "RenderType" = "Opaque" }

		Pass
		{
			// ...
		}
    }

	SubShader
    {
		// 渲染半透明物体时用此Pass替代
        Tags { "RenderType" = "Transparent" }

		Pass
		{
			// ...
		}
    }
}

那么我们的主摄像机在渲染时,将会去遍历场景中的所有物体,如果物体使用的Shader的RenderType标签为Opaque,那么相机将使用“LaoWang/CameraReplace”中的第一个Pass渲染该物体;如果物体使用的Shader的RenderType标签为Transparent,那么相机将使用“LaoWang/CameraReplace”中的第二个Pass渲染该物体;如果物体使用的Shader的RenderType标签既不为Opaque也不为Transparent,那该物体将不会渲染。
查找的标签是否一定要指定为RenderType呢?不是的。只是因为Unity中内置的着色器都有这个标签,所以我们一般就指定为它。
Unity中相机输出法线纹理、深度纹理其实就是使用了这种技术,如果某天你发现用相机输出深度纹理时发现不包含某个物体,很有可能就是那个物体的Shader的RenderType标签没有设置或者没有设置正确。

3.1.2 描边思路

①创建一个额外的相机,使用Camera.CopyFrom拷贝主摄像机的参数,并将位置、旋转设置成一样
②设置额外相机的Culling Mask
③创建一个Renderer Texture,假设名为rt,其长宽可以设置为屏幕大小,但是屏幕大小这么大的纹理内存占用比较大,没必要的话建议使用较小的分辨率
④额外相机的Target Texture设置为步骤③创建的Renderer Texture
⑤创建一个脚本,继承自MonoBehaviour,实现OnRenderImage(RenderTexture source, RenderTexture destination)方法,并挂载到主摄像机上
⑥新建一个额外相机渲染的Shader,RenderType标签设置为Opaque,片元着色中只输出纯色;该Shader用于额外相机的着色器替换
⑦额外相机调用SetReplacementShader,第一个参数为步骤⑥创建的Shader,第二个参数为RenderType

SetReplacementShader(Shader.Find(""), "RenderType");

⑧在OnRenerImage方法中,先对rt进行高斯模糊,高斯模糊会让额外相机看到的物体的轮廓往外扩,高斯模糊后的图像再与rt做差得到描边,然后再与主摄像机看到的画面叠加即可

3.1.3 问题点

这种做法有几个问题,首先是需要额外创建出一个摄像机并对其进行管理,Camera本身属于Unity3D场景管理中比较重的对象,他的背后应该还涉及视锥切割,排序等一系列复杂的操作,对于仅需要绘制几个简单物体的操作来说太浪费计算资源了。另外需要绘制的对象需要有单独的层,如果本身已经由其他需要跟其他同类物体指定一个layer的话就不太方便操作了。最后渲染的第一步与后几步分开了,由于最终需要将结果输出到主摄像机上,这意味着两个摄像机上都有一些需要维护的脚本。

3.2 Render Command

3.2.1 思路

使用Render Command实现描边的思路与使用相机的着色器替代技术的原理是一样的,只是不再是用一个额外的相机去渲染,而是直接使用Render Command来处理额外渲染的这一步骤了。
Command Buffer实现描边的过程

3.2.2 Command Buffer

Command Render主要使用Command Buffer来实现,其就是对OpenGL\DirectX这些底层渲染接口的API进行了封装,其内部预定义一系列的渲染指令,我们使用起来非常方便。
官方文档详细API
解释下我们描边用到的代码。
先实例化一个CommandBuffer,名字设置为“Render Outline”,这里设置名字主要是方便我们在Frame Debugger中定位到对应的渲染流程。

m_RenderCommand = new CommandBuffer
{
    name = "Render Outline"
};

CommandBuffer命名的作用
创建一个材质球,用于CommandBuffer的渲染。

m_OutlineMaterial = new Material(Shader.Find(OutlineShader));

然后先将CommandBuffer的背景画面给清空。
使用DrawRenderer将需要额外渲染的物体添加到CommandBuffer的队列中。
下面的示例代码表示在绘制CommandBuffer中的几个物体时,使用的材质为m_OutlineMaterial中的第一个Pass(参数中的第二个0)。

// 顺序将渲染任务加入RenderCommand中
m_RenderCommand.ClearRenderTarget(true, true, Color.clear);
for (int i = 0; i < OutlineObjects.Length; ++i)
{
    m_RenderCommand.DrawRenderer(OutlineObjects[i], m_OutlineMaterial, 0, 0);
}

然后创建一个渲染纹理,并使用Graphics.SetRenderTarget将渲染目标设置为刚创建的渲染纹理。
再调用Graphics.ExecuteCommandBuffer。
即按照CommandBuffer的设置进行渲染,渲染之后的结果即为刚创建的渲染纹理。

m_OutlineMaterial.SetColor("_OutlineColor", outlineColor);
RenderTexture outlineColorRt = RenderTexture.GetTemporary(Screen.width, Screen.height);
Graphics.SetRenderTarget(outlineColorRt);
Graphics.ExecuteCommandBuffer(m_RenderCommand);

CommandBuffer不再使用时,一定要释放。

m_RenderCommand.Clear();

3.2.3 完整代码

PostEffectOutline.cs

using UnityEngine;
using UnityEngine.Rendering;

[DisallowMultipleComponent]
[RequireComponent(typeof(Camera))]
public class PostEffectOutline : MonoBehaviour
{
    private const string OutlineShader = "LaoWang/PostEffect/Outline";

    private Material m_OutlineMaterial;
    private CommandBuffer m_RenderCommand;
    public Renderer[] OutlineObjects;

    public Color outlineColor = Color.red;

    [Range(1, 8)]
    public int downSampleScale = 2;                 // 降采样比例
    [Range(0, 4)]
    public int blurIterations = 1;                  // 高斯模糊迭代次数
    [Range(0.2f, 3.0f)]
    public float blurSpread = 0.6f;                 // 高斯模糊

    private void Awake()
    {
        m_RenderCommand = new CommandBuffer
        {
            name = "Render Outline"
        };

        m_OutlineMaterial = new Material(Shader.Find(OutlineShader));
    }

    void OnEnable()
    {
        // 顺序将渲染任务加入RenderCommand中
        m_RenderCommand.ClearRenderTarget(true, true, Color.clear);
        for (int i = 0; i < OutlineObjects.Length; ++i)
        {
            m_RenderCommand.DrawRenderer(OutlineObjects[i], m_OutlineMaterial, 0, 0);
        }
    }

    void OnDisable()
    {
        m_RenderCommand.Clear();
    }

    void OnDestroy()
    {
        m_RenderCommand.Clear();
    }

    private void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        //1. 绘制颜色
        m_OutlineMaterial.SetColor("_OutlineColor", outlineColor);
        RenderTexture outlineColorRt = RenderTexture.GetTemporary(Screen.width, Screen.height);
        Graphics.SetRenderTarget(outlineColorRt);
        Graphics.ExecuteCommandBuffer(m_RenderCommand);
        // 用于测试
        //Graphics.Blit(outlineColorRt, destination);
        //RenderTexture.ReleaseTemporary(outlineColorRt);

        //2. 降采样
        int rtW = Screen.width >> downSampleScale;
        int rtH = Screen.height >> downSampleScale;
        RenderTexture blurRt = RenderTexture.GetTemporary(rtW, rtH);
        blurRt.filterMode = FilterMode.Bilinear;
        Graphics.Blit(outlineColorRt, blurRt);

        //3. 高斯模糊
        RenderTexture blurTemp = RenderTexture.GetTemporary(rtW, rtH);
        for (int i = 0; i < blurIterations; ++i)
        {
            m_OutlineMaterial.SetFloat("_BlurSize", 1.0f + i * blurSpread);
            // 水平模糊
            Graphics.Blit(blurRt, blurTemp, m_OutlineMaterial, 1);
            // 垂直模糊
            Graphics.Blit(blurTemp, blurRt, m_OutlineMaterial, 2);
        }

        // 用于测试
        //Graphics.Blit(blurRt, destination);

        //4. 叠加
        m_OutlineMaterial.SetTexture("_OutlineColorTex", outlineColorRt);
        m_OutlineMaterial.SetTexture("_BlurTex", blurRt);
        Graphics.Blit(source, destination, m_OutlineMaterial, 3);

        RenderTexture.ReleaseTemporary(outlineColorRt);
        RenderTexture.ReleaseTemporary(blurRt);
        RenderTexture.ReleaseTemporary(blurTemp);
    }
}

PostEffect_Outline.shader

Shader "LaoWang/PostEffect/Outline"
{
    Properties
    {
		_OutlineColor ("Outline Color", color) = (1.0, 0, 0, 1.0)
        _MainTex ("Texture", 2D) = "white" {}
		_BlurSize ("Blur Size", float) = 1.0
    }
    SubShader
    {
        Tags { "RenderType" = "Opaque" }

		CGINCLUDE

		#include "UnityCG.cginc"

		fixed4 _OutlineColor;
		sampler2D _MainTex;
        half4 _MainTex_TexelSize;
		float _BlurSize;

		struct v2f
		{
			float4 pos : SV_POSITION;
			half2 uv[5] : TEXCOORD0;
		};

		v2f vertBlurVertical(appdata_img v)
		{
			v2f o;
			o.pos = UnityObjectToClipPos(v.vertex);

			half2 uv = v.texcoord;

			o.uv[0] = uv;
			o.uv[1] = uv + float2(0.0, _MainTex_TexelSize.y * 1.0) * _BlurSize;
			o.uv[2] = uv - float2(0.0, _MainTex_TexelSize.y * 1.0) * _BlurSize;
			o.uv[3] = uv + float2(0.0, _MainTex_TexelSize.y * 2.0) * _BlurSize;
			o.uv[4] = uv - float2(0.0, _MainTex_TexelSize.y * 2.0) * _BlurSize;

			return o;
		}

		v2f vertBlurHorizontal(appdata_img v)
		{
			v2f o;
			o.pos = UnityObjectToClipPos(v.vertex);

			half2 uv = v.texcoord;

			o.uv[0] = uv;
			o.uv[1] = uv + float2(_MainTex_TexelSize.x * 1.0, 0.0) * _BlurSize;
			o.uv[2] = uv - float2(_MainTex_TexelSize.x * 1.0, 0.0) * _BlurSize;
			o.uv[3] = uv + float2(_MainTex_TexelSize.x * 2.0, 0.0) * _BlurSize;
			o.uv[4] = uv - float2(_MainTex_TexelSize.x * 2.0, 0.0) * _BlurSize;

			return o;
		}

		fixed4 fragBlur(v2f i) : SV_Target
		{
			float weight[3] = {0.4026, 0.2442, 0.0545};
			fixed3 sum = tex2D(_MainTex, i.uv[0]).rgb * weight[0];

			for(int it = 1; it < 3; it++)
			{
				sum += tex2D(_MainTex, i.uv[it]).rgb * weight[it];
				sum += tex2D(_MainTex, i.uv[2*it]).rgb * weight[it];
			}

			return fixed4(sum, 1.0);
		}

		ENDCG

		ZTest Always 
		Cull Off 
		ZWrite Off

		pass
		{
			CGPROGRAM

			#pragma vertex vert
			#pragma fragment frag

            v2f_img vert (appdata_img v)
            {
                v2f_img o;
                o.pos = UnityObjectToClipPos(v.vertex);
				o.uv = v.texcoord;
                return o;
            }

            fixed4 frag (v2f_img i) : SV_Target
            {
                return _OutlineColor;
            }

			ENDCG
		}

		pass
		{
			NAME "GAUSSIAN_BLUR_VERTICAL"

			CGPROGRAM

			#pragma vertex vertBlurVertical
			#pragma fragment fragBlur

			ENDCG
		}

		pass
		{
			NAME "GAUSSIAN_BLUR_HORIZONTAL"

			CGPROGRAM

			#pragma vertex vertBlurHorizontal
			#pragma fragment fragBlur

			ENDCG
		}

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment fragOutline

			sampler2D _BlurTex, _OutlineColorTex;
			half4 _BlurTex_TexelSize, _OutlineColorTex_TexelSize;

            v2f_img vert (appdata_img v)
            {
                v2f_img o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = v.texcoord;
                return o;
            }

			fixed4 fragOutline(v2f_img i) : SV_Target
			{
				fixed4 scene = tex2D(_MainTex, i.uv);
				fixed4 blur = tex2D(_BlurTex, i.uv);
				fixed4 outlieColor = tex2D(_OutlineColorTex, i.uv);
				fixed4 outline = blur - outlieColor;
		
				fixed4 final = scene * (1 - all(outline.rgb)) + _OutlineColor * any(outline.rgb);
				return final;
			}

			ENDCG
		}
    }
	FallBack off
}

4 完整工程

博主个人博客本文链接。
链接:https://pan.baidu.com/s/1AhKWJxMaQI89vAwv8RE-vQ
提取码:xaaz

5 参考文章

  • 32
    点赞
  • 91
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
### 回答1: Unity3D是一款非常流行的游戏开发引擎,它提供了丰富的功能和工具,使开发人员能够轻松创建各种类型的游戏。其中一个强大的功能就是模型描边shader,它可以增强游戏中的视觉效果。 模型描边shader是一种特殊的着色器程序,用于给游戏中的3D模型添加轮廓线。通过描边shader,我们可以让模型的边缘线条更加清晰、醒目,让物体在场景中更加凸显出来。 使用Unity3D的模型描边shader非常方便。首先,我们需要将描边shader应用于相应的材质上。然后,通过调整一些参数,如描边宽度、颜色等,可以实现不同的效果。在游戏运行时,模型的边缘线条就会被自动描绘出来,使得物体在场景中更加鲜明。 这个功能对于游戏开发人员来说非常实用。通过使用模型描边shader,我们可以增强游戏中物体的可视性,使它们在复杂的环境中更容易被玩家注意到。而且,模型描边shader还可以用于创建一些特殊效果,如描边发光等,让游戏更加生动有趣。 总的来说,Unity3D的模型描边shader是一个非常方便实用的功能。它提供了丰富的定制选项,使开发人员能够轻松地改善游戏的视觉效果。无论是用于增强物体的可视性,还是为游戏添加特殊效果,模型描边shader都是一个非常有用的工具,可以使游戏更加出色。 ### 回答2: Unity3D模型描边Shader是一种非常方便实用且强大的技术。该Shader能够为模型的边缘添加一个特殊的描边效果,从而使模型在视觉上更加突出和立体。 使用Unity3D模型描边Shader非常方便。开发者只需将该Shader应用于目标模型的材质中,然后调整描边效果的参数即可。这些参数包括描边的颜色、宽度、锐度等。通过简单的参数调整,开发者可以获得不同效果的描边效果。 此外,Unity3D模型描边Shader还具有强大的功能。开发者可以根据需要自定义描边的样式,如添加渐变效果、使用图片纹理等。这样,描边效果不再局限于简单的线条,而是可以根据需求进行更加丰富多样的设计。 使用Unity3D模型描边Shader可以带来许多好处。首先,它能够增强模型的可视性,使其在游戏或应用中更加突出。其次,描边效果能够帮助玩家或用户更好地理解模型的形状和结构。再次,通过调整描边的参数,开发者可以在远近不同的场景中实现不同的效果,提升用户体验。 总之,Unity3D模型描边Shader是一种非常方便实用且强大的技术。它不仅能够为模型增加视觉效果,还能够提升用户体验,使游戏或应用的视觉呈现更加出色和吸引人。 ### 回答3: Unity3D模型描边Shader是一种非常方便实用且强大的功能。这个Shader可以在模型的边缘添加一条描边效果,使得模型在场景中更加突出和鲜明。 使用Unity3D模型描边Shader非常简单。首先,我们需要在Unity中创建一个材质,并将该材质的Shader类型设置为描边Shader。接下来,我们可以调整描边的颜色、宽度和透明度等属性,以满足我们的需求。 当我们将这个材质应用到模型上时,模型的边缘将自动显示出描边效果。不仅如此,Unity还提供了一些额外的功能,如控制描边的深度和三维感以及添加高光效果等。 使用Unity3D模型描边Shader的好处不仅仅是增加模型的视觉效果,它还可以提高游戏的性能。相较于其他方法,使用Shader进行描边可以在不增加更多几何体或复制模型的情况下实现,从而减少显卡的负载。 此外,Unity3D模型描边Shader还可以很容易地与其他特效和Shader进行结合。我们可以通过修改Shader代码来实现更多的效果,如闪烁、动态描边和不同颜色的描边等。这使得我们可以根据具体的游戏需求来定制和优化描边效果。 综上所述,Unity3D模型描边Shader是一项非常方便实用且强大的功能。它可以轻松地为模型添加描边效果,提高游戏的视觉效果和性能。无论是开发者还是玩家,都可以从中受益并创造出更加出色的游戏作品。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值