复数的物理意义

原文地址:https://www.zhihu.com/question/23234701/answer/26017000

复数最直观的理解就是旋转!

4*i*i = -4

就是“4”在数轴上旋转了180度。

那么4*i就是旋转了90度。

另外,e^t是什么样呢?

但当你在指数上加上i之后呢?

变成了一个螺旋线。是不是和电磁场很像?(想拿欧拉公式去跟女生炫学术的男生注意了:她们,真的,不CARE)

当然,更重要的意义在于复数运算保留了二维信息。

假如我让你计算3+5,虽然你可以轻松的计算出8,但是如果让你分解8你会有无数种分解的方法,3和5原始在各自维度上的信息被覆盖了。
但是计算3+5i的话,你依然可以分解出实部和虚部,就像上图那样。

基于以上两个理由,用复数来描述电场与磁场简直完美到爆棚!
我们即可以让电场强度与复数磁场强度相加而不损失各自的信息,又满足了电场与磁场90度垂直的要求。另外,一旦我们需要让任何一个场旋转90度,只要乘一个“i”就可以了

正弦波在频域可以看作是自然数中的“1”,可以构成其他数字的基础元素。当你需要5的时候,你可以看成是1*5(基础元素的五倍)也看以看成2+3(一个基础元素2倍与基础元素3倍的和)。这些用基础元素构成新元素的运算是线性运算。
但是现在你如何用线性运算吧2sin(wt)变换成4sin(wt+pi/6)呢?

利用欧拉公式,我们可以将任何一个正弦波看作其在实轴上的投影。假如两个不同的正弦波,可以用数学表达为:

好了,现在如果我想用第一个正弦波利用线性变换为第二个,我们就只需要将A乘对应的系数使其放大至B(本例为乘2),然后将θ1加上一定的角度使其变为θ2(本例为加30度),然后将得到的第二个虚数重新投影回实轴,就完成了在实数中完全无法做到的变换。

这种利用复指数来计算正弦波的方法也对电磁波极其适用,因为电磁波都是正弦波,当我们需要一个电磁波在时间上延迟/提前,或是在空间上前移/后移,只需要乘一个复指数就可以完成对相位的调整了。

———————————————————————————–

复数不仅有意义,而且可以用图示来优雅地解释。

1、实函数与数轴变换

[公式]

大家都认识y=e^x,对于这样的初等函数,我们从小就学会使用直角坐标系来刻画它们:


它们的特点都大同小异:把实数轴对应到实数轴。然而,既然是一维函数,用二维图像来描述未免太过奢侈。如果我们把数轴涂上不同颜色,再把一条新数轴上对应的函数值涂上相应颜色,就可以清晰地用数轴-数轴对应来展示函数这一关系:

可以发现每个函数的作用无非是在有些地方把数轴往中间压了压,在有些地方又把数轴往两边扯了扯(观察图中小棒棒之间的间距是变窄还是变宽):

  • e^x越往左越挤压数轴,越往右越拉伸数轴
  • x^2离0越远,对数轴的拉伸越厉害(在图上左半边图像和右半边图像重叠在了一起)。如果有一个小球在实数轴上向右滑行,那么它的像则先向左滑行到0,然后再向右滑行。
  • x^3离0越远,对数轴的拉伸比楼上更厉害,但是不同的是,向右滑行的小球的像也一直向右滑行。

是挤压还是拉伸,就看函数在那一点的导数的绝对值是小于1还是大于1。因此导数大小的意义就是局部小区间在变换下的伸缩倍数。导数正负符号的意义是小区间是否反向,比如第二个函数x^2在x小于0时导数也小于零,那么指向右方的数轴负数部分经过变换指向了左方。

2. 复数与平面变换
既然可以用上面的数轴-数轴对应来描述一维函数,那么类似地,就可以用平面-平面对应来描述二维函数。我们用一个复数表示平面上的点,用字母i区分纵坐标,就可以来研究复数函数w=f(z)的性质,其中z=x+iy,w=u+iv。假设我们已经默认了复数的运算:

  • 加法:z+w=(x+u)+i(y+v)
  • 乘法:zw=(xu-yv)+i(xv+yu)
  • 极坐标分解:z=re^it=rcos(t)+i*rsin(t),其中r是复数代表的平面向量到原点的距离,t是和横轴正方向的夹角。

拿出一个涂色的平面网格(从左上开始逆时针依次涂成红黄蓝绿色),把每个网点的像算出来,按顺序连起来,就可以来研究复函数了。

2.1. 复数的加法:

  • 从图中可知,加法就是平面的平移,平移量恰好是那个复数对应的平面向量。

2.2 复数的乘法:

根据上面的运算法则很容易得到函数w=iz的二维对应关系是[x,y]=>[-y,x],画在图上就是:

仔细看可以发现,各点乘以i的效果是平面逆时针旋转了90度,也就是

[公式]

弧度。

各点乘以e^it的后果是平面逆时针旋转t弧度,这里是30度。

乘以一个一般的复数,就是把整个平面按它对应的角度旋转t弧度,再均匀放大r倍。

因此,复数的加法就是自变量对应的平面整体平移,复数的乘法就是平面整体旋转和伸缩,旋转量和放大缩小量恰好是这个复数对应向量的夹角和长度。二维平移和缩放是一维左右平移伸缩的扩展,旋转是一个至少要二维才能明显的特征,限制在一维上,只剩下旋转0度或者旋转180度,对应于一维导数正负值(小线段是否反向)。

3. 复变函数与伸缩旋转

如果在每一个点处的旋转、放缩和平移量都不同(导数不同),就可以得到比较复杂的复数函数,举个例子:

3.1 

w=e^z

e^z=e^(x+iy)=e^x+e^iy,从上一小节的知识可知,e^z的作用就是把平面上每个点按自己对应的坐标放大e^x倍、旋转y弧度。我们立即可以猜测这个函数在x较大的地方放大的倍数更多,因为放大率e^x更大;在x轴上只伸缩不旋转,因为没有e^iy旋转分量;在y轴上只旋转不伸缩,因为没有e^x放缩分量:

  • 请看左图中的横向中轴,它在右图中的像也是横向中轴,只不过左边压缩,右边扩展,这正是我们一开始就提到的一维指数函数。而这个图,恰好就是一开始那个数轴-数轴对应朝两边扩展形成平面-平面对应的结果
  • 再请看左图中的竖直中轴,它在右图发生了弯曲,贴在了单位圆周上,因此变成了一系列纯旋转的复数乘子。这一点在一维中可完全没有类似物,请谨慎类比。
  • 其他点介于纯粹旋转和纯缩放之间。最后,请你回过头再仔细看看这幅图,你会发现这几段话也适用于图中的每个小正方形。小正方形变换前后的旋转和伸缩比例对应于函数的导数,本例中函数的导数就是原函数自己。

3.2. w=z^3+10

  • 加10就是整体向右平移10个单位,可以最后再看。
  • 咱们来看w=z^3,令z=re^it,可以得到:w=r^3e^i*3t,这说明单位圆以内(r<1)函数压缩,单位圆以外(r>1)函数拉伸,离原点越远拉伸越厉害,正方形网格应该越来越大。
  • 原正方形的四个彩色顶点的角度是135、225、315和45度,分别乘以3再取余360到[0,360]度之间变成45、315、225、135。因此正方形的像从左上逆时针看颜色从红黄蓝绿变成了绿蓝黄红。

图像也和上面的分析完全吻合:

举上面两个例子是想向大家展示伸缩和旋转是优雅地解释复数的有力工具。

4. 复变函数和小正方形
接着我们随便看几个复数函数对应的平面变换图像:

漂亮吧,但是且慢!为什么第二个函数图像比较丑?因为二维函数很复杂,有一小类二维函数的变量之间具有一定关系,导致的结果是虽然整体变换多姿多彩,但是如果只观察局部,这些函数一定把足够小的小正方形变成小正方形,不会压扁它或拆散它,只不过平面不同地方小正方形放缩和旋转程度不同。第二个函数就不属于这种特殊的函数类。

这种性质很好,图像很美的函数称为解析函数,它的变量之间的联系称为柯西黎曼方程,局部小正方形的放缩和旋转幅度恰好等于这个复函数在那一点的导数值(和第一段一维函数的原理极其类似,在那里一维导数用来刻画伸缩和左右方向)。简单的一维函数,可以唯一地向两边扩展成为对应的复解析函数。

如果把初始的正方形网格用极坐标进行参数化,解析函数仍然把小正方形变换为小正方形,与上图对应的图像为:

以后看到复变(准确地说是解析)函数,可要记得它们的本质是对平面局部做旋转和缩放,但保持小正方形形状不变。而一个复数就是一个能把平面进行均匀缩放和旋转的乘子。最后,请记得我的彩色正方形!

使用MaterialPropertyBlock来替换Material属性操作[转]

在Unite 2017 中曾提到了使用材质属性块的优化建议,笔者为此做了特别的研究、测试和验证,并将实验结论和测试工程在此分享,希望能对大家的研发和优化有所帮助。


一、官方文档

Unite 2017 国外技术专场中,Arturo Núñez在《Shader性能与优化专题》中的原话是:

Use MaterialPropertyBlock Is faster to set properties using a MaterialPropertyBlock rather than material.SetFloat(); Material.SetColor();

首先,我特意查找了下关于MaterialPropertyBlock的官方文档,文档是这样说的:材质属性块被用于Graphics.DrawMesh和Renderer.SetPropertyBlock两个API,当我们想要绘制许多相同材质但不同属性的对象时可以使用它。例如你想改变每个绘制网格的颜色,但是它却不会改变渲染器的状态。

我们来看看Renderer这个类,它包含了Material,SharedMaterial这两个属性;GetPropertyBlock,SetPropertyBlock这两个函数,其中两个属性是用来访问和改变材质的,而两个函数是用来设置和获取材质属性块的。我们知道,当我们操作材质共性时,可以使用SharedMaterial属性,改变这个属性,那么所有使用此材质的物件都将会改变,而我们需要改变单一材质时,需要使用Material属性,而在第一次使用Material时其实是会生成一份材质拷贝的,即Material(Instance)。

二、实验

首先声明两个数组,一个用来保存操作材质,另一个用来保存操作材质属性块。

GameObject[] listObj = null;
GameObject[] listProp = null;

然后在Start函数中做初始化工作,我们在屏幕左侧空间生成ObjCount个球体Sphere,用来处理材质,在屏幕右侧空间生成ObjCount个球体Sphere,用来处理材质属性块。

void Start () {
        colorID = Shader.PropertyToID("_Color");
        prop = new MaterialPropertyBlock();
        var obj = Resources.Load("Perfabs/Sphere") as GameObject;
        listObj = new GameObject[objCount];
        listProp = new GameObject[objCount];
        for (int i = 0; i < objCount; ++i)
        {
            int x = Random.Range(-6,-2);
            int y = Random.Range(-4, 4);
            int z = Random.Range(-4, 4);
            GameObject o = Instantiate(obj);
            o.name = i.ToString();
            o.transform.localPosition = new Vector3(x,y,z);
            listObj[i] = o;
        }
        for (int i = 0; i < objCount; ++i)
        {
            int x = Random.Range(2, 6);
            int y = Random.Range(-4, 4);
            int z = Random.Range(-4, 4);
            GameObject o = Instantiate(obj);
            o.name = (objCount + i).ToString();
            o.transform.localPosition = new Vector3(x, y, z);
            listProp[i] = o;
        }
    }

然后我们在Update函数中响应我们的操作,这里我使用按键上下健位来操作。

void Update () {
        if (Input.GetKeyDown(KeyCode.DownArrow))
        {
            Stopwatch sw = new Stopwatch();
            sw.Start();
            for (int i = 0; i < objCount; ++i)
            {
                float r = Random.Range(0, 1f);
                float g = Random.Range(0, 1f);
                float b = Random.Range(0, 1f);
                listObj[i].GetComponent<Renderer>().material.SetColor("_Color", new Color(r, g, b, 1));
            }
            sw.Stop();     
            UnityEngine.Debug.Log(string.Format("material total: {0:F4} ms", (float)sw.ElapsedTicks *1000 / Stopwatch.Frequency));
        }
        if (Input.GetKeyDown(KeyCode.UpArrow))
        {
            Stopwatch sw = new Stopwatch();
            sw.Start();
            for (int i = 0; i < objCount; ++i)
            {
                float r = Random.Range(0, 1f);
                float g = Random.Range(0, 1f);
                float b = Random.Range(0, 1f);
                listProp[i].GetComponent<Renderer>().GetPropertyBlock(prop);
                prop.SetColor(colorID, new Color(r, g, b, 1));
                listProp[i].GetComponent<Renderer>().SetPropertyBlock(prop);             
            }
            sw.Stop();
            UnityEngine.Debug.Log(string.Format("MaterialPropertyBlock total: {0:F4} ms", (float)sw.ElapsedTicks * 1000 / Stopwatch.Frequency));
        }
    }

这时,我们再来看一下对比数据:

从结果对比来看,确实使用材质属性块要快于使用材质,其消耗将近是操作材质耗时的四分之一。同时不管是材质还是材质属性块,第一次操作比后面的操作耗时要大。尤其是材质,可见在第一次使用材质改变属性操作时,其拷贝操作消耗还是非常大的。

当然上面的代码还是有优化空间的,因为每次去获取Renderer组件时都是GetComponent的形式来获取的,我们可以在Start时将其保存一下。null

Renderer[] listRender = null;
Renderer[] listRenderProp = null;

...
listRender[i] = o.GetComponent<Renderer>();
...
listRenderProp[i] = o.GetComponent<Renderer>();
...

再来看下运行对比数据:

同时我也通过Profiler的Memory模块,切换进Detailed选项,对其进行采样,可以发现在Sence Memory下面会有Material的拷贝(材质操作导致,而材质属性操作不会)。这也验证了操作材质时会有实例化存在,而使用材质属性块则不存在实例化。

三、游戏中处理

正如官方文档介绍材质属性块一样,Unity地形引擎正是使用材质属性块来绘制树的,所有的树使用的是相同材质,但是每棵树有不同的颜色、缩放和风因子。对于大场景大世界来说,我们肯定是动态加载地图的,这个时候我们可以配合GPU Instance来进一步提高性能,使用GPU Instance有两个优点:1)省去实体对象本身的开销;2)减少DrawCall的作用,同时还能减少动态合批的CPU开销和静态合批的内存开销,可谓一举多得。遗憾的是只能在Open GL ES 3.0以上的设备上使用。对于一些游戏中存在自定义皮肤颜色玩法的,材质属性块的优势就可以发挥出来了:当你想让100个不同玩家同屏时,如果使用材质操作颜色属性,那么首先就存在100份材质拷贝的实例;其次,材质操作属性本身就比材质属性块操作要慢那么点,在性能优化中一毫秒的优化就是胜利,那么这里一毫秒那里一毫秒,累积起来就不得了了。

四、相关工程

Arturo Núñez 的shader性能与优化的工程下载地址:
https://github.com/ArturoNereu/ShaderProfilingAndOptimization

本次测试工程:
https://pan.baidu.com/s/1qXPGhTa

原文地址:https://blog.uwa4d.com/archives/1983.html

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