Unity UI 优化学习总结(转)

Unity UI 基础

Canvas(画布),和名字一样,是 UI 绘制的地方,Unity 的渲染系统用其来提供一个可绘制的分层几何。负责将ui几何合批成适合的网格,提交绘制命令给 Unity 的图形系统,这整个过程叫做 rebatch 或者 batch build。当 Canvas 其子节点下包含 Canvas Renderer 的节点需要进行 rebatch 的时候,就会被标记为脏。

Sub-canvas 是嵌套在 Canvas 里的 Canvas,它与其父节点是隔离的,Sub-Canvas 的 rebuild 不会逼迫父节点重建几何,反之亦然,不过还是存在一些边界情况。。比如父节点变化导致子节点大小变化。

Graphic 是 unity 的 ui C# 基类,大部分 unity ui 都是 通过继承 MaskableGraphic 类来实现的,后者比前者多了可遮罩(Maskbale)的能力。该类让 ui 描述并创建出自身需要绘制的网格。

Layout 组件为 ui 提供了布局管理能力,也就是控制 RectTransform 的 位置和大小。它不会受到Graphics的影响。

Graphci 与 Layout 都依赖于 CanvasUpdateRegistry 类。它会定位 Graphic 与 Layout 是否需要更新并加入更新队列,在所在 Canvas 的 willRenderCanvases 事件被触发时对队列中的对象执行真正的更新。这些更新被称作 rebuild。不要和上面的 rebatch 弄混了。

Batch building 的过程,Canvas 会将其 ui 元素生成的 mesh 组合并生成合适的绘制命令给 Unity 渲染系统。并且过程的结果会被缓存并重用,直到 Canvas 重新被标记为脏。这会在组合的网格发生变化时发生。Canvas 会根据子节点里带有 Canvas Renderer 组件的 ui 来生成 网格,但是不包括 Sub-Canvas 的子节点,也就是说每个 Canvas 单独负责自身的 Batch building。Batch building 的过程会对根据深度、重叠测试、材质等条件对各个 Mesh 进行排序、分组、合并,这个过程是多线程的,在移动端(核心少)与桌面端(核心多)会呈现相当大的差异。

Rebuild 的过程就是布局与网格(这里指的不是最终 rebatch 生成的网格,而是 Graphic 子类或者说 ui 生成的网格)重新计算的过程。CanvasUpdateRegistry 会维护若干队列,里面有标记了 Layout 或者 Graphic 脏标记的 ui 节点,在 PerformUpdate 方法里依次对各个队列节点作如下处理

  1. Layout 进行更新(会改变节点 RectTransform ,该组件会在 Graphic 更新时被引用)
  2. 进行裁剪(官方文档这里举例说 Mask,但事实上内置ui实现了 IClipper 接口的只有 RectMask2D,Mask 是通过更新 stencil buffer然后ui都会测试的原理来实现的)
  3. 然后对 Graphic 进行更新(更新节点上的 Canvas Render 网格、材质、贴图)

这样所有 ui 节点的网格、材质、贴图就确定了。之后 Canvas 会从 Canvas Render 中取出这些东西,并重新生成网格(绘制顺序实际上是在这里确定的)与绘制命令交给渲染系统(也就是 rebatch)。事实上上述可以进一步细分成prelayoy、layout、postlayout等等。细节可以直接看 源码 。

Profile UI 性能热点

任何的性能优化,第一步都是剖析性能热点,常见的 ui 性能热点有

  • GPU 填充率过高
  • Canvas batch 的 Rebuild 花费时间过长
  • 过多的 Canvas batch Rebuild
  • 顶点生成过多

Unity 的 UI 性能剖析工具,有两种,一种是编辑器自带的,一种是第三方外部工具,第三方工具往往结果更准确,但是有平台、编译模式等等限制,这里只说 Unity 内置剖析工具。

可以观察在 Unity Profiler 的 CPU Usage 里 PostLateUpdate.PlayerUpdate 下 UIEvent.WillRenderCanvas 的时间片占比来确定当前 UI 的 CPU开销。

SendWillRenderCanvas 即为 rebuild 的过程,Canvas 则为 batch build 的过程。也可以更省事,直接看 profiler 里的 UI 与 UI Detail 查看 rebuild Layout 与 Render 的开销,还有就是 Canvas 的自身或者累计的(即包括 Sub-Canvas) 的 batch 次数、顶点与GameObject 次数,甚至还有合批中断原因。还会显示触发的ui事件。不过 batch viewer(也就是profiler 下面那一坨)只能在编辑器运行模式下使用。

此外还可以使用 GPU profiler 查看 Transparent 部分开销来确定 UI 的 GPU 使用情况,从而排查填充率,确定 overdraw 等问题。

除了使用 Profiler 之外,还可以选择使用 Unity Frame Debugger(帧调试器),它会完整地显示某一帧的渲染过程,ui 当然也在其中,而且与 Profiler 不同,在非编辑器运行模式下也能使用。ui 的 drawCall 所在位置会受 Canvas render Mode 的影响, overlay 的模式会显示在 Canvas.RenderOverlays 组,否则则会在相关联的摄影机 Render.TransparentGeometry 组下。可以看出 ui 是在透明队列中绘制的,也就是说要按照深度从后往前绘制才能满足透明的需求。所以 Canvas 在 batch 的过程需要按深度排序 Canavs render,之后会检查拥有相同材质贴图(也就是有相同渲染状态,这种可以用一个drawCall 搞定)的 Canavs render,检查他们之间是否有不同材质且与他们发生重叠的 Canavs render。如果有则无法将它们合批,要拆成两个 batch,原因是如果这时候强行把他们合批,都无法满足绘制透明物体需要从后往前绘制的需求,会导致不正确的透明绘制结果。此外这里还要注意 hierarchy 中的顺序也是排序的依据或者说深度。

收集到结果之后就可以开始分析啦~~

  • Canvas.BuildBatch 或者 Canvas::UpdateBatches 占用过多 CPU 时间,说明 batch build 过度,需要考虑拆分成多个 Canvas。
  • 填充率过高,则考虑优化 overdraw。
  • WillRenderCanvas 大部分时间花在 IndexedSet_Sort 或者 CanvasUpdateRegistry_SortLayoutList 方法上(代表了过多的 Layout rebuild,不一定只是这两方法,具体看源码),则考虑减少布局组件的使用,UI Profiler 中 Layout 也是同理。
  • UI Profiler 中 Graphics 或者PopulateMesh 之类的方法开销占比大,则说明 Graphic 的 rebuild 是性能热点。这个要结合具体组件分析。
  • 如果发现 Canvas.SendWillRenderCanvases 每帧都被调用,那就要找找是什么导致了 Canvas 的频繁 rebuild,考虑通过拆分 Canvas 来缓解。

填充率、Canvas、输入

填充率

UI 节省 GPU 片元管线的负载的方法主要集中在两点

  • 减少复杂片元着色器的复杂度
  • 减少像素的采样数目

因为实际项目大部分的时候使用的都是 unity 的标准 ui 材质,所以主要的问题还是出在后者,也就是填充率过高上,ui占据过多的屏幕,或者合批数目太多都会导致过多的 overdraw 从而产生填充率高的问题。有如下解决方法

  • 隐藏不可见ui,因为项目里经常有全屏覆盖的ui,而且隐藏 Canvas 很简单,所以这个是相当简单实在的方法,不过要注意的是单纯控制透明度为0的确能“隐藏”ui,但是 ui 的绘制开销不会变。但是 ui 是通过 Graphics(勾选了 Raycast Target)来找到响应目标的,所以如果有只想响应点击但是不想显示的情况,可以通过调整透明度隐藏,然后勾选 Canvas Render 上的 Cull Transparent Mesh 来达到裁剪完全透明 ui 的目的。
  • 简化ui结构,尽量让美术把图整体切出来,这样相当于人力“合批”了 ui,从而消除了 overdraw,需要整体改变色调不要通过叠图混合颜色的方式来做,要用材质或者控制顶点色来搞。那种为了屏幕适配或者合理分层的节点而没有显示作用的节点要搞成空节点(即不带 Graphic 的节点)。
  • 关闭不可见的摄影机,这个不仅可以用于 UI 的优化,大部分游戏除了 ui 还会有实际场景,当有覆盖全屏的 ui 时,将那些不可见图形(ui 或者 3d 场景)的输出摄影机关了,相当于把他们的绘制开销完全砍了,可以简单直观有效地减小 GPU 压力 以及 drawCall,甚至有时对于非全屏ui,也可以通过截图(可能还要模糊)铺满背景的方式来硬怼出全屏ui,从而可以关闭摄影机。这种优化和上面隐藏 ui 的优化根据时一样的,但是应用范围更广。
  • 使用简单的shader,Unity 的默认 ui shader 为了通用使用了 stencil buffer 之类的东西,可以针对应用场景将 shader 替换成更简单的自定义 shader,除了采样 sprite 啥事不做之类的。

UI Canvas 的 rebuild

Canvas 的 rebuild 产生性能问题的原因主要有两种

  • Canvas 下 ui 元素过多,导致大量 ui 元素在合批过程的分析、排序开销过大,这个过程的复杂度是大于 O(n) 的,所以会 ui 元素数目影响很大。
  • Canvas 频繁地被标记为脏,即使 Canvas 只有很少的变动,但还是会刷新从而花费用于刷新

所以要尽可能地将会频繁刷新的 ui 拆分成多个Canvas,因为哪怕 Canvas 下一个 ui 元素的变动都会导致整个Canvas 的 重新合批(这里说得是合批(batch),单个 ui 元素的变动(指会对 Cannvasvas Rednder 产生影响,无论是材质、贴图、网格)一般不会导致其他 ui 的 rebuild)。不过也不能过多,因为 Canvas 相互之间是不会合批的。“动静分离”是 Unity Canvas 拆分的一个指导依据,将频繁刷新的部分,比如进度条,还有长时间静态的部分,比如各种背景,拆成两部分,这样前者的刷新就不会影响后者。

上面讲到在两个 ui 元素之间插入不可合批(拥有不同渲染状态,材质或者贴图不同)的且与它们重叠的 ui 元素,就会打断 ui 的合批,他们会被拆成三份网格,从而增加 drawcall。因为排序是按照 hierarchy 下的顺序来的,所以可以通过调整层级顺序来解决。此外也可以通过调整 ui 大小来恢复合批,或者将贴图打成一张图集从而能共享渲染状态而合批。这三种方法分别是通过消除 插入元素之间 重叠 不可合批 这三个条件来达到合批的,总之只要知道打断合批或者说成功合批的条件,就能针对性地进行优化。

可以通过 profiler 或者帧调试器来确定合批打断原因,具体方法上文已提及,此处不赘述。

Unity UI 的 输入与 Raycasting

Unity 的 UI 输入流程是 EventSystem 接受各类来自输入模块的 event,然后传递给各个 Graphics Raycaster,然后 Graphics Raycaster 找到其下所有勾选了 RayCaster Target 的且 active 的 Graphics,然后检测输入坐标是否命中了 ui 的 RectTransform 描述的节点,是的话,沿着层级向上遍历,对实现了 ICanvasRaycastFilter 的节点检查命中,如果命中就加入列表,知道没有父节点或者走到了勾选了 overrideSorting 的 CanvasGroup, 最后将列表按照深度从前到后排序(与渲染相反),如果 Blocking Objects 不是 None,那么深度位于ui 前的非 ui 且会相应点击的物体也会加入列表,从而遮挡住 ui 的点击。如果没有,那么列表种第一个检测为命中的 ui 就会响应输入事件。详情还是看官方源码 。

从上述过程可以看出开销的关键在于减少列表的长度, 如果不需要相应点击的 ui,就不要勾选 Raycast Target,RectTranform 不要无谓地拉太大等等,总之就是可以将不需要的 Graphic 从响应目标列表移除的手段的都可以达到优化的目的。

其他优化技术与建议

还有一些会导致难以维护或者有其他副作用的优化手段。

  • 利用 RectTransform 代替 Layout 来实现简单的布局需求。
  • 隐藏 Canvas(Canvas 组件而不是 Canvas 所在节点) 而不是具体 ui 节点,这样需要显示回来的时候不需要 rebuild Canvas。
  • 直接修改ui源码(一定要慎重考虑,仔细分析性能热点是否真的需要改源码而不是其他手段来解决)

总结

总的来说,UGUI 这套东西还是建立在 Unity 的基本渲染系统上,batch build 之类的优化点只要熟悉 Unity 的动态合批机制就不难搞定。不过在基本功能上添加了很多类似于布局、输入处理之类方便 ui 开发的特性。可以看出很多地方对性能不是很友好,比如所有 ui 都当成透明的来处理,很容易导致overdraw。不过不能站着说话不腰疼,至少这是一套有相当完成度的系统,性能方面的问题也可以有很多解决方案的,不至于束手无策,而且 Unity 官方还开放了一部分源码,更有利于性能分析与优化,可惜还是没有把 Canvas 合批的代码开放。

原文地址:https://zhuanlan.zhihu.com/p/266997416

Unity Shader-Decal贴花[转]

简介

Decal(贴花)。这个效果其实非常常见,比如人物脚底的选中圈,还有最著名的就是CS里面的喷漆效果了,当年大战的时候没少和人互喷,要是说到CS(Quake),那这个技术真的是相当古老了。不过Decal的实现方式有很多种,今天来分别实现一下Self Decal,Additive贴片叠加,Projector投影,Mesh Decal,Deferred Decal(Depth Normal Decal)这几种方式。

Self Decal

我在网上查了半天,似乎也没有对这种贴花方式的命名,索性就叫它Self Decal吧。也可能是这种方式太捞(low)了,大佬们都不care了。所谓Self,也就是直接在同一个Shader里面在叠加一张贴图方式。可以直接采样uv,也可以将mesh制作第二套uv。相比于直接把贴图都做在Albedo中,使用Decal可以更加方便的替换贴图,而且二套uv方便控制特殊的效果,比如捏脸系统里面经常有的脑门上画个咒印之类的。

关于这种Decal,不做太多介绍了。Unity官方资源也包含一个名叫Decal的Surface Shader,我这里就直接简单粗暴地用Unlit类型的vf shader了。最后的叠加使用的是类似Alpha Blend的SrcAlpha OneMinusSrcAlpha,将Decal的颜色和原始颜色根据Decal的Alpha值进行插值:

/********************************************************************
 FileName: SelfDecal.shader
 Description: SelfDecal二套uv自身贴花效果
 history: 12:7:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
Shader "Decal/SelfDecal"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
		_DecalTex ("Decal", 2D) = "black" {}
	}
	
	SubShader
	{
		Tags { "RenderType"="Opaque" }
		LOD 100
 
		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "UnityCG.cginc"
 
			struct appdata
			{
				float4 vertex : POSITION;
				float2 uv : TEXCOORD0;
				float2 uv_decal : TEXCOORD1;
			};
 
			struct v2f
			{
				float4 uv : TEXCOORD0;
				float4 vertex : SV_POSITION;
			};
 
			sampler2D _MainTex;
			sampler2D _DecalTex;
			float4 _DecalTex_ST;
			
			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.uv.xy = v.uv;
				//offset,tiling二套uv调整不同的decal
				o.uv.zw = TRANSFORM_TEX(v.uv_decal, _DecalTex);
				return o;
			}
			
			fixed4 frag (v2f i) : SV_Target
			{
				fixed4 col = tex2D(_MainTex, i.uv.xy);
				fixed4 decal = tex2D(_DecalTex, i.uv.zw);
				
				col.rgb = lerp(col.rgb, decal.rgb, decal.a);
 
				return col;
			}
			ENDCG
		}
	}
}

这里我们直接用二套uv进行decal贴花,可以更加容易控制需要细节部分的贴花同时也不影响主纹理的效果。于是我掏出了使用十分不熟练的3DMax给我最近常用的小狮子展了二套uv,贴图的话就使用我最喜欢的羊驼表情包啦:

效果如下,在石狮子的胸前就贴上了羊驼的头像:

由于我们通过TRANSFORM_TEX对二套uv进行了处理,所以我们可以很容易地通过二套uv的Tiling和Offset来修改贴花显示的内容,来一个羊驼表情幻灯片:


AlphaBlend贴片Decal

上面我们实现了自身Decal效果。但是也有很大的限制,比如我们希望在两个对象连接处叠加一个Decal,我们不方便给每个东西都额外去展uv,直接用世界空间或者物体空间坐标采样也不一定靠谱。其实我们可以考虑一种更简单的方法实现Decal,甚至不需要写代码。因为这个Shader实在太常见啦。可以说是最简单粗暴,而又很容易实现贴花细节的一种方法。

我们可以使用Particle Alpha Blended或者Mobile版本的Alpha Blended Shader,简单来说就是最基本的采样贴图输出的AlphaBlend的Shader就可以达到效果,比如下面的Shader:

/********************************************************************
 FileName: AlphaBlendedDecal.shader
 Description: AlphaBlend Shader
 history: 7:12:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
Shader "Decal/AlphaBlendedDecal"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
	}
	SubShader
	{
		 Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" }
		
		Blend SrcAlpha OneMinusSrcAlpha
		Lighting Off
		ZWrite Off
 
		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "UnityCG.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 col = tex2D(_MainTex, i.uv);
				return col;
			}
			ENDCG
		}
	}
}

这一次,我们就可以找一个面片,然后把我们的需要作为Decal的内容赋给面片,然后把面片贴在地面上或者墙上就可以得到效果啦。很多游戏场景中的地面,墙面细节,如特殊的污渍,血迹,如果不用SelfDecal或者地形刷混合权重的方式的话,直接叠上去一个面片也是一个不错的选择。比如下面我在地面上叠了两个血迹的Decal,即使在两个地砖接缝也可以使用:


Projector Decal

上面的两种Decal可以实现很多贴花的效果了,但是二者都有自身的局限。Self Decal可以贴合任意物体,但是需要物体自身展uv,无法实现一个Decal覆盖多个物体,仅适用于一些预制的贴花替换;而Alpha Blend叠片方式的Decal,可以任意摆放位置,也可以跨物体摆放,但是有一个很重要的问题,如果是平面,如墙面和地面,我们可以直接使用一个面片叠加,而如果是复杂物体之间的贴花效果,直接用面片叠加无法完美贴合,自己做一个贴合的模型也不现实。所以,是时候找一找普适性更高一些的贴花方法了(不过,上面两种贴花也很常用,毕竟如果用简单的方法就能达到效果,何必要浪费时间和性能呢)。

Unity内置的Projector是一个不错的选择。Projector可以做很多好玩的效果,不仅包括贴花,还可以实现阴影等效果。


Projector的使用

使用Projector,相当于Unity会对需要投影的对象使用投影材质重新渲染一遍。不过这个材质的Shader需要是特制的,而不是随便的Shader都可以得到正确的效果。因为我们需要在Shader里面根据投影器传入的Project矩阵进行uv计算,得到采样的uv值。我们可以参考Unity官方aras-p大佬的Projector Shader来编写我们自己的投影Shader,我将Blend模式改为了Blend SrcAlpha OneMinusSrcAlpha,然后直接输出颜色。Shader中采样并没有使用正常的uv坐标,而是通过两个Unity内置矩阵变换顶点,进而在fragment shader中采样,关于这两个内置矩阵的解释,可以参考这里。另外,我们可以通过FalloffTex实现在投影距离内的颜色渐变,通过采样一张falloff贴图,通过纹理的uv横向实现投影的衰减,使用Falloff有两个好处,一方面可以防止Projector背面物体也被投影,出现穿帮的问题;另一方面,通过远距离衰减可以得到更加自然的投影效果,而且也可以防止远处超出投影“视锥体”远裁剪面时出现的调变问题。投影的Shader代码如下:

/********************************************************************
 FileName: ProjectorDecal.shader
 Description: Projector Decal Shader
 history: 7:12:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
Shader "Decal/ProjectorDecal" 
{ 
	Properties 
	{
		_Color ("Main Color", Color) = (1,1,1,1)
		_DecalTex ("Cookie", 2D) = "" {}
		_FalloffTex ("FallOff", 2D) = "white" {}
	}
 
	Subshader 
	{
		Pass 
		{
			ZWrite Off
			Fog { Color (0, 0, 0) }
			ColorMask RGB
			Blend SrcAlpha OneMinusSrcAlpha
			Offset -1, -1
 
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			#include "UnityCG.cginc"
			
			struct v2f 
			{
				float4 uvDecal : TEXCOORD0;
				float4 uvFalloff : TEXCOORD1;
				float4 pos : SV_POSITION;
			};
			
			float4x4 unity_Projector;
			float4x4 unity_ProjectorClip;
			fixed4 _Color;
			sampler2D _DecalTex;
			sampler2D _FalloffTex;
			
			v2f vert (float4 vertex : POSITION)
			{
				v2f o;
				o.pos = UnityObjectToClipPos (vertex);
				o.uvDecal = mul (unity_Projector, vertex);
				o.uvFalloff = mul (unity_ProjectorClip, vertex);
				return o;
			}
			
			fixed4 frag (v2f i) : SV_Target
			{
				fixed4 decal = tex2Dproj (_DecalTex, UNITY_PROJ_COORD(i.uvDecal));
				decal *= _Color;
 
				fixed falloff = tex2Dproj (_FalloffTex, UNITY_PROJ_COORD(i.uvFalloff)).r;
				decal *= falloff;
				return decal;
			}
			
			ENDCG
		}
	}
}


这样,我们直接在场景中挂一个Projector组件,然后使用上面Shader的材质,将投影器对准我们需要投影的对象,就可以得到完美贴合多个不规则对象的投影效果了,如下图,血迹的效果完美贴合在了地面和石狮子上:


通过FrameDebugger我们可以看到,Projector的Decal是通过额外的Pass渲染的,上图的石狮子和地面都额外进行了一次渲染,所以Projector也是由一定的性能代价的,如果一个Projector覆盖太多的物体可能会导致批次上升:


Projector的原理

Unity内置的Projector基本已经可以满足我们的需求了,不过还是了解一下Projector的原理更好。注,这里本人暂时先不考虑剔除相关的内容,仅考虑Projector渲染本身的原理。

首先我们观察一下默认的Projector组件的各种参数,远裁剪面,近裁剪面,FOV,Orthographic等,很明显这几个参数跟Camera的参数是一致的,可以认为Projector实际上就是在Projector位置摆放一个相机不过并非使用这个相机渲染,还是使用原始的相机进行渲染,只是通过这个相机的视矩阵和投影矩阵对uv进行转化。我们可以自定义一个脚本实现这一步的转化:

/********************************************************************
 FileName: ProjectorEffect.cs
 Description: Projector原理,自定义实现
 history: 4:1:2019 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
 
[ExecuteInEditMode]
public class ProjectorEffect : MonoBehaviour
{
 
    private Camera projectorCam = null;
    public Material projectorMaterial = null;
 
 
    private void Awake()
    {
        projectorCam = GetComponent<Camera>();
    }
 
    private void Update()
    {
        var projectionMatrix = projectorCam.projectionMatrix;
        projectionMatrix = GL.GetGPUProjectionMatrix(projectionMatrix, false);
        var viewMatirx = projectorCam.worldToCameraMatrix;
        var vpMatrix = projectionMatrix * viewMatirx;
        projectorMaterial.SetMatrix("_ProjectorVPMatrix", vpMatrix);
    }
 
 
}

配套的Shader如下:

/********************************************************************
 FileName: ProjectorEffect.shader
 Description: Custom Projector Effect Shader
 history: 4:1:2019 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
Shader "Decal/ProjectorEffectShader" 
{ 
	Properties 
	{
		_Color ("Main Color", Color) = (1,1,1,1)
		_DecalTex ("Cookie", 2D) = "" {}
	}
 
	Subshader 
	{
		Pass 
		{
			ZWrite Off
			Fog { Color (0, 0, 0) }
			ColorMask RGB
			Blend SrcAlpha OneMinusSrcAlpha
			Offset -1, -1
 
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			#include "UnityCG.cginc"
			
			struct v2f 
			{
				float4 uvDecal : TEXCOORD0;
				float4 pos : SV_POSITION;
			};
			
			float4x4 _ProjectorVPMatrix;
			
			fixed4 _Color;
			sampler2D _DecalTex;
			
			v2f vert (float4 vertex : POSITION)
			{
				v2f o;
				o.pos = UnityObjectToClipPos (vertex);
				//转化到Decal相机的投影空间
				float4x4 decalMVP = mul(_ProjectorVPMatrix, unity_ObjectToWorld);
				float4 decalProjectSpacePos = mul(decalMVP, vertex);
				//转化到Decal所对应的屏幕空间(0,1)区间位置
				o.uvDecal = ComputeScreenPos(decalProjectSpacePos);
				return o;
			}
			
			fixed4 frag (v2f i) : SV_Target
			{
				fixed4 decal = tex2Dproj (_DecalTex, i.uvDecal);
				decal *= _Color;
				return decal;
			}
			
			ENDCG
		}
	}
}

这样,通过借用在投影位置的相机的VP矩阵,以及渲染对象本身的ObjectToWorldMatrix,我们就可以实现由投影对象物体空间坐标转化到投影相机空间位置的坐标,再将其转化到(0,1)区间,就得到了一个完美的在投影相机视角的uv分布了。

使用自定义Decal的效果与直接Projector的Decal效果类似:


在Decal对应的相机视角的效果如下:


Mesh Decal

下面再来看一种十分常用的Decal实现,这里我们称之为Mesh Decal。简单来说,不是通过再渲染一遍物体实现,而是通过一个代理的Mesh进行碰撞检测,最常用的就是立方体,我们可以直接使用Unity内置的立方体即可,首先遍历所有MeshRenderer,然后获得与代理Mesh相交的物体,这一步我们可以通过Unity内置的bounds.Intersects实现判断,获得相交物体后,我们就需要对每一个相交的物体的所有顶点信息进行遍历,将所有的三角形转化到Decal代理Mesh所在的空间中,然后用立方体的六个面对每个三角形进行裁剪,如果三角形三个顶点都在面外,则直接舍弃,如果三角形三个顶点都在面内,则直接计入,这样就生成了经过立方体裁剪过的Mesh网格,我们仅保留了在Decal立方体内的我们需要的网格,将其渲染就可以得到Mesh Decal了,不过还有一个重要的问题,仅仅有三角形是不够的,我们还需要法线和uv。法线比较好办,有了三角形,我们就可以直接通过三角形的两个边直接cross生成垂直于该三角形的法线,重点在于Mesh Decal的uv,我们可以考虑一下,直接使用一个立方体作为Decal,那么实际上我们需要的uv就是立方体所对应的uv,只是将立方体上的内容投影到了目标对象上,再进一步,我们可以直接考虑将立方体的物体空间位置转化为uv,Unity的标准立方体是(-0.5,0.5)区间的,我们可以将其+0.5即转化为(0,1)区间。但是立方体的坐标位置是三维的,我们所需要的是二维的,所以我们只需要在其中取其中二维,当然,最好是取其中变化范围较大的两个维度,避免出现与被投影面垂直时导致出现uv拉伸的情况(这个问题不仅在MeshDecal会遇到,在Deferred Decal中也是很常见的问题,具体解决方法在Deferred Decal再讨论)。

这里我们先根据上面的基本思路实现一版最近本的贴花生成器:

/********************************************************************
 FileName: MeshDecal.cs
 Description: 生成贴合目标对象的贴花Mesh
 history: 30:12:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
using System.Collections.Generic;
using UnityEngine;
 
public class MeshDecal : MonoBehaviour
{
    private MeshFilter meshFilter = null;
    private MeshRenderer meshRenderer = null;
    private Mesh currMesh = null;
 
    private List<Vector3> vertices = new List<Vector3>();
    private List<Vector3> normals = new List<Vector3>();
    private List<int> indices = new List<int>();
    private List<Vector2> texcoords = new List<Vector2>();
 
    private void Awake()
    {
        meshFilter = GetComponent<MeshFilter>();
        meshRenderer = GetComponent<MeshRenderer>();
       
    }
 
    [ContextMenu("Gen Decal")]
    public void GetTargetObejcts()
    {
        var mrs = FindObjectsOfType<MeshRenderer>();
        foreach(var r in mrs)
        {
            //剔除Decal自身
            if (r.GetComponent<MeshDecal>() != null)
                continue;
            //遍历所有的MeshRenderer判断和自身立方体相交的Mesh进行Decal Mesh生成
            if (meshRenderer.bounds.Intersects(r.bounds))
            {
                GenerateDecalMesh(r);
            }   
        }
        //将存储的数据生成Unity使用的Mesh
        GenerateUnityMesh();
    }
 
   
    public void GenerateDecalMesh(MeshRenderer target)
    {
        var mesh = target.GetComponent<MeshFilter>().sharedMesh;
        //GC很高,可以优化
        var meshVertices = mesh.vertices;
        var meshTriangles = mesh.triangles;
        
        var targetToDecalMatrix = transform.worldToLocalMatrix * target.transform.localToWorldMatrix;
        for(int i = 0; i < meshTriangles.Length; i = i + 3)
        {
            var index1 = meshTriangles[i];
            var index2 = meshTriangles[i + 1];
            var index3 = meshTriangles[i + 2];
 
            var vertex1 = meshVertices[index1];
            var vertex2 = meshVertices[index2];
            var vertex3 = meshVertices[index3];
            //将网格的三角形转化到Decal自身立方体的坐标系中
            vertex1 = targetToDecalMatrix.MultiplyPoint(vertex1);
            vertex2 = targetToDecalMatrix.MultiplyPoint(vertex2);
            vertex3 = targetToDecalMatrix.MultiplyPoint(vertex3);
 
            var dir1 = vertex1 - vertex2;
            var dir2 = vertex1 - vertex3;
            var normalDir = Vector3.Cross(dir1, dir2).normalized;
 
            var vectorList = new List<Vector3>();
            vectorList.Add(vertex1);
            vectorList.Add(vertex2);
            vectorList.Add(vertex3);
 
            var result = CollisionChecker.CheckCollision(vectorList);
            if (result == true)
                AddPolygon(vectorList.ToArray(), normalDir);
          
        }
    }
 
    public void AddPolygon(Vector3[] poly, Vector3 normal)
    {
        int ind1 = AddVertex(poly[0], normal);
 
        for (int i = 1; i < poly.Length - 1; i++)
        {
            int ind2 = AddVertex(poly[i], normal);
            int ind3 = AddVertex(poly[i + 1], normal);
 
            indices.Add(ind1);
            indices.Add(ind2);
            indices.Add(ind3);
        }
    }
 
    private int AddVertex(Vector3 vertex, Vector3 normal)
    {
        //优先寻找是否包含该顶点
        int index = FindVertex(vertex);
        if (index == -1)
        {
            vertices.Add(vertex);
            normals.Add(normal);
            //物体空间的坐标作为uv,需要从(-0.5,0.5)转化到(0,1)区间
            float u = Mathf.Lerp(0.0f, 1.0f, vertex.x + 0.5f);
            float v = Mathf.Lerp(0.0f, 1.0f, vertex.y + 0.5f);
            texcoords.Add(new Vector2(u, v));
            return vertices.Count - 1;
        }
        else
        {
            //已包含时,将该顶点的法线与新插入的顶点进行平均,共享的顶点,需要修改法线
            normals[index] = (normals[index] + normal).normalized;
            return index;
        }
    }
 
    private int FindVertex(Vector3 vertex)
    {
        for (int i = 0; i < vertices.Count; i++)
        {
            if (Vector3.Distance(vertices[i], vertex) < 0.01f) return i;
        }
        return -1;
    }
 
    public void GenerateUnityMesh()
    {
        currMesh = new Mesh();
 
        currMesh.Clear(true);
 
        currMesh.vertices = vertices.ToArray();
        currMesh.normals = normals.ToArray();
        currMesh.triangles = indices.ToArray();
        currMesh.uv = texcoords.ToArray();
 
        vertices.Clear();
        normals.Clear();
        indices.Clear();
        texcoords.Clear();
 
        meshFilter.sharedMesh = currMesh;
    }
 
}
 
public class CollisionChecker
{
    /// <summary>
    /// 标准立方体的六个面
    /// </summary>
    private static Plane frontPlane = new Plane(Vector3.forward, 0.5f);
    private static Plane backPlane = new Plane(Vector3.back, 0.5f);
    private static Plane upPlane = new Plane(Vector3.up, 0.5f);
    private static Plane downPlane = new Plane(Vector3.down, 0.5f);
    private static Plane leftPlane = new Plane(Vector3.left, 0.5f);
    private static Plane rightPlane = new Plane(Vector3.right, 0.5f);
 
    /// <summary>
    /// 立方体每个面进行裁剪,判断三角形是否在面外
    /// </summary>
    /// <param name="plane"></param>
    /// <param name="vectorList"></param>
    /// <returns></returns>
    private static bool CheckCollision(Plane plane, List<Vector3> vectorList)
    {
        var outSidePointCount = 0;
        for(var current = 0; current < vectorList.Count; current++)
        {
            //var next = (current + 1) % vectorList.Count;
            var v1 = vectorList[current];
            //var v2 = vectorList[next];
 
            if (plane.GetSide(v1))
                outSidePointCount++;
        }
        if (outSidePointCount == vectorList.Count)
            return false;
        return true;
    }
 
    public static bool CheckCollision(List<Vector3> vectorList)
    {
        var result = CheckCollision(frontPlane, vectorList);
        result |= CheckCollision(backPlane, vectorList);
        result |= CheckCollision(upPlane, vectorList);
        result |= CheckCollision(downPlane, vectorList);
        result |= CheckCollision(leftPlane, vectorList);
        result |= CheckCollision(rightPlane, vectorList);
 
        return !result;
    }
 
}

我们使用一个Unity默认的立方体上使用该脚本,通过右键可以直接生成与当前立方体相交对象的Mesh Decal,然后Decal本身的Shader使用类似上面的Alpha Blend的Shader即可:

似乎已经有了Decal效果,然而这个Decal似乎若隐若现,而且随着视角变化会疯狂闪动,其实就是Z-Fighting现象,原因在于我们生成的Mesh与原有Mesh完全重合,这种情况下深度测试无法保证精准测试。要想解决这个问题,我们需要对生成的Mesh做一下进一步处理,即让Mesh沿着法线方向再偏移一些(外扩一些),与之前我们在描边效果中实现的外扩思想类似:

这样就可以避免ZFighing的问题,得到正常的Decal效果:

不过,还有一个很重要的问题,就是我们在考虑立方体面与三角形裁剪时,仅考虑了最简单的裁剪方式,即要么都在,要么都不在的方式,而实际上,如果一个三角形有一部分在立方体内,一部分在外面时,这种情况还是很常见的,此时我们的Decal效果就不对了,比如下图中立方体上边界的锯齿状,以及石狮子下部分石台被框住但是也没有生成MeshDecal的情况:

所以,裁剪时遇到三角形部分在我们需要将三角形切割,生成贴合边界的新顶点,我们重写一下裁剪部分的代码,对每个顶点遍历立方体的每个面,如果当前顶点在对应面内部,加入list,查看下一顶点,如果对应的下一顶点在外面,则用面裁剪两个点,得到在面上的新顶点,加入list,直至所有顶点以及六个面循环结束。


完整的剔除的代码如下(注,未考虑优化,直接使用GC&消耗爆表):

/********************************************************************
 FileName: MeshDecal.cs
 Description: 生成贴合目标对象的贴花Mesh
 history: 30:12:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
using System.Collections.Generic;
using UnityEngine;
 
public class MeshDecal : MonoBehaviour
{
    private MeshFilter meshFilter = null;
    private MeshRenderer meshRenderer = null;
    private Mesh currMesh = null;
 
    private List<Vector3> vertices = new List<Vector3>();
    private List<Vector3> normals = new List<Vector3>();
    private List<int> indices = new List<int>();
    private List<Vector2> texcoords = new List<Vector2>();
 
    private void Awake()
    {
        meshFilter = GetComponent<MeshFilter>();
        meshRenderer = GetComponent<MeshRenderer>();
 
    }
 
    [ContextMenu("Gen Decal")]
    public void GetTargetObejcts()
    {
        var mrs = FindObjectsOfType<MeshRenderer>();
        foreach (var r in mrs)
        {
            //剔除Decal自身
            if (r.GetComponent<MeshDecal>() != null)
                continue;
            //遍历所有的MeshRenderer判断和自身立方体相交的Mesh进行Decal Mesh生成
            if (meshRenderer.bounds.Intersects(r.bounds))
            {
                GenerateDecalMesh(r);
            }
        }
        //将存储的数据生成Unity使用的Mesh
        GenerateUnityMesh();
    }
 
 
    public void GenerateDecalMesh(MeshRenderer target)
    {
        var mesh = target.GetComponent<MeshFilter>().sharedMesh;
        //GC很高,可以优化
        var meshVertices = mesh.vertices;
        var meshTriangles = mesh.triangles;
 
        var targetToDecalMatrix = transform.worldToLocalMatrix * target.transform.localToWorldMatrix;
        for (int i = 0; i < meshTriangles.Length; i = i + 3)
        {
            var index1 = meshTriangles[i];
            var index2 = meshTriangles[i + 1];
            var index3 = meshTriangles[i + 2];
 
            var vertex1 = meshVertices[index1];
            var vertex2 = meshVertices[index2];
            var vertex3 = meshVertices[index3];
            //将网格的三角形转化到Decal自身立方体的坐标系中
            vertex1 = targetToDecalMatrix.MultiplyPoint(vertex1);
            vertex2 = targetToDecalMatrix.MultiplyPoint(vertex2);
            vertex3 = targetToDecalMatrix.MultiplyPoint(vertex3);
 
            var dir1 = vertex1 - vertex2;
            var dir2 = vertex1 - vertex3;
            var normalDir = Vector3.Cross(dir1, dir2).normalized;
 
            var vectorList = new List<Vector3>();
            vectorList.Add(vertex1);
            vectorList.Add(vertex2);
            vectorList.Add(vertex3);
            //if (Vector3.Angle(Vector3.forward, -normalDir) <= 90.0f)
            {
                CollisionChecker.CheckCollision(vectorList);
                if (vectorList.Count  > 0)
                    AddPolygon(vectorList.ToArray(), normalDir);
            }
        }
    }
 
    public void AddPolygon(Vector3[] poly, Vector3 normal)
    {
        int ind1 = AddVertex(poly[0], normal);
 
        for (int i = 1; i < poly.Length - 1; i++)
        {
            int ind2 = AddVertex(poly[i], normal);
            int ind3 = AddVertex(poly[i + 1], normal);
 
            indices.Add(ind1);
            indices.Add(ind2);
            indices.Add(ind3);
        }
    }
 
    private int AddVertex(Vector3 vertex, Vector3 normal)
    {
        //优先寻找是否包含该顶点
        int index = FindVertex(vertex);
        if (index == -1)
        {
            vertices.Add(vertex);
            normals.Add(normal);
            //物体空间的坐标作为uv,需要从(-0.5,0.5)转化到(0,1)区间
            float u = Mathf.Lerp(0.0f, 1.0f, vertex.x + 0.5f);
            float v = Mathf.Lerp(0.0f, 1.0f, vertex.z + 0.5f);
            texcoords.Add(new Vector2(u, v));
            return vertices.Count - 1;
        }
        else
        {
            //已包含时,将该顶点的法线与新插入的顶点进行平均,共享的顶点,需要修改法线
            normals[index] = (normals[index] + normal).normalized;
            return index;
        }
    }
 
    private int FindVertex(Vector3 vertex)
    {
        for (int i = 0; i < vertices.Count; i++)
        {
            if (Vector3.Distance(vertices[i], vertex) < 0.01f) return i;
        }
        return -1;
    }
 
    public void HandleZFighting(float distance)
    {
        for (int i = 0; i < vertices.Count; i++)
        {
            vertices[i] += normals[i] * distance;
        }
    }
 
    public void GenerateUnityMesh()
    {
        currMesh = new Mesh();
        HandleZFighting(0.001f);
 
 
        currMesh.Clear(true);
 
        currMesh.vertices = vertices.ToArray();
        currMesh.normals = normals.ToArray();
        currMesh.triangles = indices.ToArray();
        currMesh.uv = texcoords.ToArray();
 
        vertices.Clear();
        normals.Clear();
        indices.Clear();
        texcoords.Clear();
 
        meshFilter.sharedMesh = currMesh;
    }
 
}
 
public class CollisionChecker
{
 
    private static List<Plane> planList = new List<Plane>();
 
    static CollisionChecker()
    {
        //front
        planList.Add(new Plane(Vector3.forward, 0.5f));
        //back
        planList.Add(new Plane(Vector3.back, 0.5f));
        //up
        planList.Add(new Plane(Vector3.up, 0.5f));
        //down
        planList.Add(new Plane(Vector3.down, 0.5f));
        //left
        planList.Add(new Plane(Vector3.left, 0.5f));
        //right
        planList.Add(new Plane(Vector3.right, 0.5f));
 
    }
 
    private static void CheckCollision(Plane plane, List<Vector3> vectorList)
    {
        var newList = new List<Vector3>();
        for (var current = 0; current < vectorList.Count; current++)
        {
            var next = (current + 1) % vectorList.Count;
            var v1 = vectorList[current];
            var v2 = vectorList[next];
            var currentPointIn = plane.GetSide(v1);
            if (currentPointIn == true)
                newList.Add(v1);
 
            if (plane.GetSide(v2) != currentPointIn)
            {
                float distance;
                var ray = new Ray(v1, v2 - v1);
                plane.Raycast(ray, out distance);
                var newPoint = ray.GetPoint(distance);
                newList.Add(newPoint);
            }
        }
        vectorList.Clear();
        vectorList.AddRange(newList);
    }
 
    public static void CheckCollision(List<Vector3> vectorList)
    {
        foreach (var curPlane in planList)
        {
            CheckCollision(curPlane, vectorList);
        }
    }
 
}

效果如下,可见,在边界处不是直接把整个三角形剔除,而是严格按照Decal立方体的边界进行剔除:

我们改用一个Alpha Blend的材质,完整效果如下,可见MeshDecal也实现了完美贴合物体的贴花效果:


Deferred Decal

除了Unity内置的Projector可以实现Decal外,我们再来看一种很常见的Decal实现方法,Deferred Decal。毕竟,在效果较好的贴花方法中,Defferred Decal是相对性能很好的一个方式(不考虑生成深度+法线本身的消耗,因为Defferred本身就需要)。

Deferred Decal的思想本身跟延迟渲染光照的思想类似,通过一个特殊的包围几何体作为贴花的代理进行渲染。其实贴花本身只是一个面片,但是一般都是使用立方体或者球体进行贴花的渲染,因为可以保证任意方向的观察效果以及拐角处的贴花。下一步就考虑怎样处理贴花的uv了,毕竟我们无法用立方体本身的uv来渲染。

这里,我们需要用到全屏的深度纹理,有可能需要用到全屏法线纹理。对于前向渲染和延迟渲染,DepthTexture都是可以得到的,但是全屏的法线纹理,对于延迟和前向渲染是不一致的。延迟渲染通过GBuffer获得,前向渲染可以通过DepthNormalTexture获得。但是不管怎样,二者的实现原理是一致的,而且Depth Normal Texture本身就是一个Mini GBuffer,所以这里索性将其统一称之为Deferred Decal了。


Depth Decal

首先,我们需要在渲染时获得屏幕空间当前深度对应的位置,也就是重建世界坐标。这个我们在后处理阶段经常使用,具体可以参考之前本人的文章:Unity Shader-深度相关内容总结。但是直接在渲染物体阶段实现深度重建,与后处理阶段略有不同。我们还是采用视空间射线三角形相似的方式,但是不需要使用屏幕后处理的uv重建,而是直接将物体转换到视空间作为边界,如下图:

A为相机对应点,C为我们要计算的vertex顶点,而D为经过光栅化插值后对应像素的点,也是我们所要求的E点所在的射线在远裁剪面的投影点,三角形AEF相似于三角形ADB。D点我们可以直接通过顶点转化到视空间进行计算,假设为Ray,AB为Ray.z,AF为已知的深度值,即对应屏幕坐标点深度计算得到的视空间距离(linear01depth * _ProjectionParam.z),根据三角形相似,我们就可以求得E点的坐标值,也就是对应的视空间坐标值。

先来看一下Shader:

/********************************************************************
 FileName: DepthDecal.shader
 Description: 根据深度的贴花效果
 history: 17:12:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
Shader "Decal/DepthDecal"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
	}
	
	SubShader
	{
		Tags {"Queue"="Transparent+100"}
		Pass
		{
			Blend SrcAlpha OneMinusSrcAlpha
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "UnityCG.cginc"
 
			struct appdata
			{
				float4 vertex : POSITION;
			};
 
			struct v2f
			{
				float4 vertex : SV_POSITION;
				float4 screenPos : TEXCOORD1;
				float3 ray : TEXCOORD2;
			};
 
			sampler2D _MainTex;
			sampler2D_float _CameraDepthTexture;
			
			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.screenPos = ComputeScreenPos(o.vertex);
				o.ray =UnityObjectToViewPos(v.vertex) * float3(-1,-1,1);
				return o;
			}
			
			fixed4 frag (v2f i) : SV_Target
			{
				//深度重建视空间坐标
				float2 screenuv = i.screenPos.xy / i.screenPos.w;
				float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, screenuv);
				float viewDepth = Linear01Depth(depth) * _ProjectionParams.z;
				float3 viewPos = i.ray * viewDepth / i.ray.z;
				//转化到世界空间坐标
				float4 worldPos = mul(unity_CameraToWorld, float4(viewPos, 1.0));
				//转化为物体空间坐标
				float3 objectPos = mul(unity_WorldToObject, worldPos);
				//剔除掉在立方体外面的内容
				clip(float3(0.5, 0.5, 0.5) - abs(objectPos));
				//使用物体空间坐标的xz坐标作为采样uv
				float2 uv = objectPos.xz + 0.5;
				fixed4 col = tex2D(_MainTex, uv);
				return col;
			}
			ENDCG
		}
		
	}
}

首先,我们需要用一个立方体进行贴花的渲染,直接使用Unity默认的立方体(1,1,1)大小的。当立方体光栅化后,在fragment阶段时,针对每一个像素点,我们可以获得当前像素点的深度值(注意,这里的这个像素点对应的并非立方体自身,而是该点出深度Buffer存储的像素点对应的位置重建得到的物体空间位置),使用这个深度值根据上述方法重建视空间坐标,进而反推得到世界空间坐标,物体空间坐标。那么,该点深度重建得到位置,与立方体边界进行比较,就可以保证在立方体内的贴花才会渲染,立方体外的贴花不进行渲染。而我们采样贴花贴图的坐标也可以直接使用转化到立方体空间内的三维向量中的两个方向进行,立方体的坐标是(-0.5,0.5),我们将其+0.5就刚好转化为(0,1)区间,这样,一个最简单的基于深度的贴花就完成了,还是我们的小狮子,这次在脑袋上贴一个血迹效果:


Depth Normal Decal


上面的Depth Decal虽然可以实现贴花,然而在某些特定情况下效果很糟糕,比如下面这种,当我们的立方体对象xz轴和要投影的对象垂直时,那么投影的结果就是一条拉长的拖尾:

我们来分析一下应该很容易可以得到原因,因为我们使用的是深度重建的物体空间位置,使用其中的xz轴作为纹理坐标进行的采样,所以y轴对应的内容就是拉伸的拖尾效果。我们可以改成xy,但是另一个轴也会出现问题。一般而言,对于不规则物体,xyz都是有变化的,这种现象不是很明显,但是一旦出现类似石狮子底座这种平面完全垂直的,拖尾现象就很严重了。

所以,要解决这个问题最简单的方法就是让xz平面不与被投影面垂直,也就是说摆放Decal的时候要风骚,不能横平竖直滴摆,而是像下面这样,斜着摆,保证没有垂直的情况,这样uv拖影的情况就会大大降低了:

虽然上面的方式可以解决,但是毕竟再风骚的走位也有失误的时候,我们没法保证所有的情况下任意面都不垂直于xz,所以还是要想一个办法来解决这个问题。另一种解决问题的方式就是,如果这个效果不好看,那么这种效果有还不如没有,所以,我们可以考虑直接根据法线方向把与xz轴垂直的面剔除掉。

当然,仅使用屏幕空间深度已经解决不了了,我们还需要一些信息来判断当前平面的朝向。这里自然而然就可以想到全屏法线了。我们判断当前像素点的法线方向,然后把一个物体空间的y轴方向(0,1,0)转化到当前屏幕法线所在的空间,然后进行dot计算夹角,如果接近垂直的阈值,那么直接Clip掉。这种做法也就是Unity官方的做法,可以参考官方Command Buffer实现的Deferred Decal的实现。为了方便,我们优先还是实现前向渲染下的Decal。即,使用DepthNormalTexture获得全屏法线,DepthNormalTexture中的法线方向是视空间的,所以我们要将(0,1,0)转化到视空间中,再直接与视空间法线dot比较:

/********************************************************************
 FileName: DepthNormalDecal.shader
 Description: 根据深度和法线的贴花效果
 history: 17:12:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
Shader "Decal/DepthNormalDecal"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
		_NormalClipThreshold("NormalClip Threshold", Range(0,0.3)) = 0.1
	}
	
	SubShader
	{
		Tags {"Queue"="Transparent+100"}
		Pass
		{
			Blend SrcAlpha OneMinusSrcAlpha
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "UnityCG.cginc"
 
			struct appdata
			{
				float4 vertex : POSITION;
			};
 
			struct v2f
			{
				float4 vertex : SV_POSITION;
				float4 screenPos : TEXCOORD1;
				float3 ray : TEXCOORD2;
				float3 yDir : TEXCOORD3;
			};
 
			sampler2D _MainTex;
			sampler2D_float _CameraDepthNormalsTexture;
			float _NormalClipThreshold;
			
			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.screenPos = ComputeScreenPos(o.vertex);
				o.ray =UnityObjectToViewPos(v.vertex) * float3(-1,-1,1);
				//将立方体y轴方向转化到视空间
				o.yDir = UnityObjectToViewPos(float3(0,1,0));
				return o;
			}
			
			fixed4 frag (v2f i) : SV_Target
			{
				//深度重建视空间坐标
				float2 screenuv = i.screenPos.xy / i.screenPos.w;
				float linear01Depth;
				float3 viewNormal;	
				float4 cdn = tex2D(_CameraDepthNormalsTexture, screenuv);
				DecodeDepthNormal(cdn, linear01Depth, viewNormal);
 
				float viewDepth = linear01Depth * _ProjectionParams.z;
				float3 viewPos = i.ray * viewDepth / i.ray.z;
				//转化到世界空间坐标
				float4 worldPos = mul(unity_CameraToWorld, float4(viewPos, 1.0));
				//转化为物体空间坐标
				float3 objectPos = mul(unity_WorldToObject, worldPos);
				//剔除掉在立方体外面的内容
				clip(float3(0.5, 0.5, 0.5) - abs(objectPos));
				
				//根据法线方向剔除与xz垂直的面投影的内容
				float3 yDir = normalize(i.yDir);
				clip(dot(yDir, viewNormal) - _NormalClipThreshold);
				
				//使用物体空间坐标的xz坐标作为采样uv
				float2 uv = objectPos.xz + 0.5;
				
				fixed4 col = tex2D(_MainTex, uv);
				return col;
			}
			ENDCG
		}
		
	}
}

同时需要配合相机开启DepthNormalTexture:

using UnityEngine;
 
[ExecuteInEditMode]
public class CameraDepthNormalHelper : MonoBehaviour
{
    public DepthTextureMode depthMode = DepthTextureMode.DepthNormals;
 
    private void OnEnable()
    {
        GetComponent<Camera>().depthTextureMode = depthMode;
    }
 
    private void OnDisable()
    {
        GetComponent<Camera>().depthTextureMode = DepthTextureMode.None;
    }
 
}

这样,还是之前的羊驼+小狮子的效果,适当调整NormalClipThreshold,就可以得到较好的效果了,在没有开启法线剔除时:

开启法线剔除后的效果:

Depth Normal Decal有一个很大的问题在于将Depth和Normal存储在同一个Buffer中,深度和法线分别占16位,就会导致深度的精度不足,重建世界坐标时就会有抖动的问题,不过经过本人的测试,如果相机的远裁剪面比较近的话,几百左右还是可以的,但是如果达到了上千就会闪,当然,Scene相机必定会闪的。要想解决这个问题,有两种方案,如果是延迟渲染,那么就直接用G-Buffer即可;如果是前向渲染,再单独渲染一遍Normal Buffer肯定的吃不消的,所以另一种方式是直接使用全屏深度斜率重建Normal,不过代价就是一遍比较费的全屏后处理,而且只能处理顶点法线,法线贴图是没有效果的.


真·Deferred Decal

最后,我们还是要来看一下真正的Deferred Decal,毕竟之前的Depth以及Depth Normal Decal都是山寨的,233。一方面,在延迟渲染下,Depth和Normal都是有的,而且都是高精度的,可以实现更加精确的效果;而另一方面由于Deferred Decal是发生在G-Buffer之后,全屏着色之前,我们可以直接写入一个颜色信息到GBuffer0也就是Albedo中,最终着色使用的仍然是默认的Deferred Shading着色器;也就是说我们可以用一个很简单的Shader实现Decal与原始场景光照效果完美融合,而不必专门再写一个特殊的带光照的Decal Shader。

我们写一个最简单的Deferred Decal系统,通过Command Buffer将渲染Decal的DC插入在Lighting之前:

/********************************************************************
 FileName: DeferredDecal.cs
 Description: 延迟贴花效果
 history: 5:1:2019 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
 
[ExecuteInEditMode]
public class DeferredDecal : MonoBehaviour {
 
    private MeshFilter meshFilter = null;
    private MeshRenderer meshRenderer = null;
 
    private CommandBuffer commandBuffer = null;
 
    private void Awake()
    {
        meshFilter = GetComponent<MeshFilter>();
        meshRenderer = GetComponent<MeshRenderer>();
    }
 
    private void OnEnable()
    {
        commandBuffer = new CommandBuffer();
        commandBuffer.SetRenderTarget(BuiltinRenderTextureType.GBuffer0, BuiltinRenderTextureType.CameraTarget);
        commandBuffer.DrawMesh(meshFilter.sharedMesh, transform.localToWorldMatrix, meshRenderer.sharedMaterial);
        Camera.main.AddCommandBuffer(CameraEvent.BeforeLighting, commandBuffer);
    }
 
    private void OnDisable()
    {
        Camera.main.RemoveCommandBuffer(CameraEvent.BeforeLighting, commandBuffer);
    }
}

然后我们修改一下之前的Depth Normal Decal,Depth改回使用_CameraDepthTexture,而Normal改为使用GBuffer中的Normal,但是有一点略微不同,GBuffer中的Normal是世界空间的,所以我们的变化均改为了世界空间进行:

/********************************************************************
 FileName: DeferredDecal.shader
 Description: 延迟贴花效果
 history: 5:1:2019 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
Shader "Decal/DeferredDecal"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
		_NormalClipThreshold("NormalClip Threshold", Range(0,0.3)) = 0.1
	}
	
	SubShader
	{
		Tags {"Queue"="Transparent+100"}
		Pass
		{
			Blend SrcAlpha OneMinusSrcAlpha
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "UnityCG.cginc"
 
			struct appdata
			{
				float4 vertex : POSITION;
			};
 
			struct v2f
			{
				float4 vertex : SV_POSITION;
				float4 screenPos : TEXCOORD1;
				float3 ray : TEXCOORD2;
				float3 yDir : TEXCOORD3;
			};
 
			sampler2D _MainTex;
			sampler2D_float _CameraDepthTexture;
			sampler2D _CameraGBufferTexture2;
			float _NormalClipThreshold;
			
			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.screenPos = ComputeScreenPos(o.vertex);
				o.ray =UnityObjectToViewPos(v.vertex) * float3(-1,-1,1);
				//将立方体y轴方向转化到世界空间,GBuffer的Normal是世界空间的
				o.yDir = mul((float3x3)unity_ObjectToWorld, float3(0,1,0));
				return o;
			}
			
			fixed4 frag (v2f i) : SV_Target
			{
				//深度重建视空间坐标
				float2 screenuv = i.screenPos.xy / i.screenPos.w;
				float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, screenuv);
				float linear01Depth = Linear01Depth(depth);
				float viewDepth = linear01Depth * _ProjectionParams.z;
				float3 viewPos = i.ray * viewDepth / i.ray.z;
				//转化到世界空间坐标
				float4 worldPos = mul(unity_CameraToWorld, float4(viewPos, 1.0));
				//转化为物体空间坐标
				float3 objectPos = mul(unity_WorldToObject, worldPos);
				//剔除掉在立方体外面的内容
				clip(float3(0.5, 0.5, 0.5) - abs(objectPos));
				
				//GBuffer取世界空间法线方向
				float3 worldNormal = tex2D(_CameraGBufferTexture2, screenuv).rgb * 2.0 - 1.0;
				//根据法线方向剔除与xz垂直的面投影的内容
				float3 yDir = normalize(i.yDir);
				clip(dot(yDir, worldNormal) - _NormalClipThreshold);
				
				//使用物体空间坐标的xz坐标作为采样uv
				float2 uv = (objectPos.xz + 0.5);
				
				fixed4 col = tex2D(_MainTex, uv);
				return col;
			}
			ENDCG
		}
	}
}

这样,我们还是使用这样一个没有光照效果的Decal Shader进行贴花,得到的贴花结果仍然是带有光照的。这里来一个对比,左侧的效果是直接叠加上去一个图的效果,没有任何附加的效果,而右侧的石狮子上的贴花,是和场景光照一致的效果(图中由于光照方向,使贴花部分显得暗淡)。

影响Normal的Deferred Decal

使用Deferred Decal,即Decal绘制在GBuffer之后,Lighting之前进行,除了上面的两个优点之外,还有一个很重要的优势在于我们除了可以影响Albedo,还可以影响任意G-Buffer中的内容,实现更加复杂的贴花效果。比如影响法线,影响occlusion,影响roughness,影响Emission等等,只要是出现在G-Buffer中的都可以。这里我们尝试一下Normal的影响。

要想影响Normal效果,我们就需要除了影响Albedo的Buffer之外,还需要影响Normal所在的Buffer,可以分别绘制,但是既然都使用Deferred Shading了,MRT神马的肯定是不在话下了,我们也可以用MRT实现一个DC同时写入两个Buffer。不过有一个地方需要注意一下,我们在实现Decal的时候使用了Normal进行穿帮位置的贴花剔除,相当于读取了Normal RT,但是还要写入这个RT,这就有问题了。一般情况下,一张RT是不允许同时读写的,在PC上可能看不出来问题,但是移动平台轻则失效,重则黑屏,所以我们需要避免对同一个RT进行同时读写操作。这里我们将GBuffer中的Normal拷贝出来一份,读取拷贝的Normal,写入原始的Normal Buffer即可。

C#代码如下:

/********************************************************************
 FileName: DeferredDecal.cs
 Description: 延迟贴花效果,写入NormalBuffer
 history: 5:1:2019 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
 
[ExecuteInEditMode]
public class DeferredDecal : MonoBehaviour {
 
    private MeshFilter meshFilter = null;
    private MeshRenderer meshRenderer = null;
 
    private CommandBuffer commandBuffer = null;
 
    private void Awake()
    {
        meshFilter = GetComponent<MeshFilter>();
        meshRenderer = GetComponent<MeshRenderer>();
    }
 
    private void OnEnable()
    {
        commandBuffer = new CommandBuffer();
        //拷贝一份临时的NormalRT用于读取,防止同RT同时读写
        var normalCopyRTId = Shader.PropertyToID("_NormalCopyRT");
        commandBuffer.GetTemporaryRT(normalCopyRTId, -1, -1);
        commandBuffer.Blit(BuiltinRenderTextureType.GBuffer2, normalCopyRTId);
 
        //同时写入Albedo和Normal
        var mrt = new RenderTargetIdentifier[] { BuiltinRenderTextureType.GBuffer0, BuiltinRenderTextureType.GBuffer2 };
        commandBuffer.SetRenderTarget(mrt, BuiltinRenderTextureType.CameraTarget);
        commandBuffer.DrawMesh(meshFilter.sharedMesh, transform.localToWorldMatrix, meshRenderer.sharedMaterial);
        commandBuffer.ReleaseTemporaryRT(normalCopyRTId);
 
        Camera.main.AddCommandBuffer(CameraEvent.BeforeLighting, commandBuffer);
    }
 
    private void OnDisable()
    {
        Camera.main.RemoveCommandBuffer(CameraEvent.BeforeLighting, commandBuffer);
    }
}

Shader代码也需要进行些许修改,首先要将输出改为两个,同时写入Normal和Albedo,然后需要采样BumpMap,并通过矩阵变换:

/********************************************************************
 FileName: DeferredDecalNormal.shader
 Description: 延迟贴花效果,同时写入Normal buffer
 history: 5:1:2019 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
Shader "Decal/DeferredDecalNormal"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
		_BumpMap ("Normals", 2D) = "bump" {}
		_NormalClipThreshold("NormalClip Threshold", Range(0,0.3)) = 0.1
	}
	
	SubShader
	{
		Tags {"Queue"="Transparent+100"}
		Pass
		{
			Blend SrcAlpha OneMinusSrcAlpha
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "UnityCG.cginc"
 
			struct appdata
			{
				float4 vertex : POSITION;
			};
 
			struct v2f
			{
				float4 vertex : SV_POSITION;
				float4 screenPos : TEXCOORD1;
				float3 ray : TEXCOORD2;
				float3 xDir : TEXCOORD3;
				float3 yDir : TEXCOORD4;
				float3 zDir : TEXCOORD5;
			};
 
			sampler2D _MainTex;
			sampler2D _BumpMap;
			sampler2D_float _CameraDepthTexture;
			sampler2D _NormalCopyRT;
			float _NormalClipThreshold;
			
			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.screenPos = ComputeScreenPos(o.vertex);
				o.ray =UnityObjectToViewPos(v.vertex) * float3(-1,-1,1);
				//将xyz立方体物体空间转化到世界空间
				o.xDir = mul((float3x3)unity_ObjectToWorld, float3(1,0,0));
				o.yDir = mul((float3x3)unity_ObjectToWorld, float3(0,1,0));
				o.zDir = mul((float3x3)unity_ObjectToWorld, float3(0,0,1));
				return o;
			}
			
			//输出改为两个,同时写入albedo和normal
			void frag (v2f i, out half4 outAlbedo : COLOR0, out half4 outNormal : COLOR1)
			{
				//深度重建视空间坐标
				float2 screenuv = i.screenPos.xy / i.screenPos.w;
				float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, screenuv);
				float linear01Depth = Linear01Depth(depth);
				float viewDepth = linear01Depth * _ProjectionParams.z;
				float3 viewPos = i.ray * viewDepth / i.ray.z;
				//转化到世界空间坐标
				float4 worldPos = mul(unity_CameraToWorld, float4(viewPos, 1.0));
				//转化为物体空间坐标
				float3 objectPos = mul(unity_WorldToObject, worldPos);
				//剔除掉在立方体外面的内容
				clip(float3(0.5, 0.5, 0.5) - abs(objectPos));
				
				//GBuffer拷贝的NormalRT取世界空间法线方向
				float3 worldNormal = tex2D(_NormalCopyRT, screenuv).rgb * 2.0 - 1.0;
				//根据法线方向剔除与xz垂直的面投影的内容
				float3 yDir = normalize(i.yDir);
				clip(dot(yDir, worldNormal) - _NormalClipThreshold);
				
				//使用物体空间坐标的xz坐标作为采样uv
				float2 uv = (objectPos.xz + 0.5);
				outAlbedo = tex2D(_MainTex, uv);
				
				//采样BumpMap
				fixed3 bump = UnpackNormal(tex2D(_BumpMap, uv));
				float3x3 normalMat = float3x3(i.xDir, i.zDir, i.yDir);
				bump = mul(bump, normalMat);
				outNormal = fixed4(bump * 0.5 + 0.5, 1.0);
			}
			ENDCG
		}
	}
}

这样,我们就实现了可以同时影响Albdeo和Normal的贴花效果,这里我们使用了一张满是噪点的法线,看一下贴花的效果:

关于Deferred Decal就到这里了,可以参考Unity官方的Command Buffer的Demo,一个重要的功能就是实现Deferred Decal。


总结

本篇blog主要讨论了一下目前常见的一些贴花的实现方式,并全部在Unity中给出了基本的实现,包括自身二套uv贴花(Self Decal),叠片贴花(Alpha Blend面片贴花),内置投影器贴花(Projector),碰撞网格构造贴花(Mesh Decal),延迟贴花的多种实现方式(Depth Decal,Depth Normal Decal,Deferred Decal,同时影响多通道的贴花)等。还是那句话,再捞(low)的方式也有有用处的地方,只有最适合自己项目的实现方式。

原文链接:https://blog.csdn.net/puppet_master/article/details/84310361