Unity Shader GrabPass使用注意的问题(转)

最近项目中碰到GrabPass多次抓图的问题,经测试发现可以每帧只抓取一次图,然后共用。后来看到网上这篇文章总结得很好,所以收藏下。

文档

关于Unity Shader中的GrabPass说明文档:

官方的ShaderLab: GrabPass
CSDN其他博主翻译的ShaderLab: GrabPass


GrabPass有两种写法

GrapPass { }
GrabPass { “TheGrabTextureName” }
两种写法的去写在哪呢。
文档中有说明,但是可能说的还是不够清楚。

我用自己总结的:

GrabPass { } 是每次Drawcall中的Shader的GrabPass使用时都会中屏幕内容抓取一次绘制的内容,并保存在默认的命为_GrabTexture的纹理中
GrabPass { “TheGrabTextureName” } 是每一帧Drawcall中的Shader的GrabPass中,第一次调用该GrabPass抓取的内容,保存在TheGrabTextureName的纹理中,后面Drawcall或是pass调用的GrabPass { “TheGrabTextureName” }只要TheGrabTextureName纹理名字与之前的GrabPass { “TheGrabTextureName” }中的TheGrabTextureName相同,都不会再执行GrabPass的过程,而直接使用之前Grab好的纹理对象内容。
下面我在实际Unity测试项目中测试结果写下来。


区别

我写了个测试用的,类似毛玻璃的模糊效果的Shader,如下:

使用GrabPass { } 的方式

// jave.lin 2020.03.04
Shader "Custom/GrabTexBlur" {
    Properties {
        _Blur ("_Blur", Range(0, 1)) = 0
    }
    SubShader {
        Tags { "Queue"="Transparent" "RenderType"="Transparent" }
        GrabPass { }
        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            struct appdata {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };
            struct v2f {
                float4 vertex : SV_POSITION;
                float4 grabPos : TEXCOORD1;
                float4 worldPos : TEXCOORD2;
            };
            sampler2D _GrabTexture;
            float4 _GrabTexture_TexelSize;
            fixed _Blur;
            fixed3 blur(float2 uv) {
                fixed3 sum = 0;
                const int blurSize = 4;
                const int initV = blurSize / 2;
                const int maxV = initV + 1;
                for (int i = -initV; i < maxV; i++) {
                    for (int j = -initV; j < maxV; j++) {
                        sum += tex2D(_GrabTexture, uv + float2(i * _GrabTexture_TexelSize.x, j * _GrabTexture_TexelSize.y));
                    }
                }
                // sum /= (blurSize + 1) * (blurSize + 1); // 这句时正确的效果
                sum /= blurSize * blurSize; // 这句时为了测试两个Sphere交集部分更亮的效果
                return sum;
            }
            v2f vert (appdata v) {
                v2f o;
                o.worldPos = mul(unity_ObjectToWorld, v.vertex);
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.grabPos = ComputeGrabScreenPos(o.vertex);
                return o;
            }
            fixed4 frag (v2f i) : SV_Target {
                i.grabPos.xy /= i.grabPos.w;
                fixed4 col = tex2D(_GrabTexture, i.grabPos.xy);
                col.rgb = lerp(col.rgb, blur(i.grabPos.xy), _Blur);
                return col;
            }
            ENDCG
        }
    }
}


应用

  • 场景中新建一个Sphere球体。
  • 创建Material材质,设置使用的Shader。
  • 给球体设置材质。

效果如下图:

Profiler > Frame Debugger

打开Frame Debugger查看绘制过程,因为shader指定在transparent的queue绘制队列,所以直接看TransparentGeometry下绘制的内容就好

可以看到有一个Grab RenderTexture的绘制

这时再复制一个毛玻璃的Sphere,然后再看看绘制过程。

这次看到有两次Grab RenderTexture的绘制
这是GrabPass { } 的绘制过程。
看看运行效果,记住这个效果,与下面的其他方式是不一样的

使用GrabPass { “TheGrabTextureName” }方式

shader还是和上面的一样,都是毛玻璃的测试shader。
只不过,这次我们给GrabPass出来的屏幕内容的纹理定义了一个名字。

GrabPass { "_GrabTexture" }

注意,如果你起的名字与默认的_GrabTexture相同也是没问题的,只要用了这种给GrabPass出来的纹理起个名字,就和没使用名字的处理流程是不一样的

然后再看看绘制过程

可以看到,这次虽然绘制了两个Sphere(黄色框那),但是Grab RenderTexture只执行了一次,因为都使用了GrabPass {“Name”}的方式,并且Name是一样的。那么后续的GrabPass就直接使用之前的纹理。

效果上会也和没起名字的不一样

两图对比一下

首先,shader中有这么一句故意使用的:

               // sum /= (blurSize + 1) * (blurSize + 1); // 这句时正确的效果
                sum /= blurSize * blurSize; // 这句时为了测试两个Sphere交集部分更亮的效果

因为少除了一些采样数据均值权重,所以整体权重比之前大,就会更亮。

GrabPass{ }的方式,在两球体交集部分会比较亮,原因是:GrabPass { } 每次Drawcall时的都重新先取拿一次当前绘制屏幕内容的内容到一个纹理中,所以第一个球体对Grab出来的屏幕像素内容处理逻辑了,导致某些像素比较亮了。但是第二个球体再次Grab出来的屏幕像素时,有些与第一个球体的像素集合有交集的本身有处理过,所以亮度本身比较高了,那么再次处理亮度,就会亮上加亮。
而GrabPass { “Name” } 的方式,区别在于,第二个球再次GrabPass { “Name” }时发现这个”Name”的纹理之前有了,就不再重新抓取当前屏幕内容了。所以使用的还是第一个球体执行毛玻璃+亮度之前的原始屏幕内容,所以交集出的部分不会更亮。第一次球体绘制的与第二次球体绘制有交集的那部分内容,都给第二次球体绘制的覆盖了。

不同Shader中使用了一样的GrabPass {“Name”}

再添加另一个shader,此shader作用就是添加亮度的,如下

Shader "Custom/GrabTexBrightness" {
    Properties {
        _Brightness ("_Brightness", Range(1, 2)) = 1
    }
    SubShader {
        Tags { "Queue"="Transparent" "RenderType"="Transparent" }
        GrabPass { "_GrabTexture" }
        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            struct appdata {
                float4 vertex : POSITION;
            };
            struct v2f {
                float4 vertex : SV_POSITION;
                float4 grabPos : TEXCOORD1;
            };
            sampler2D _GrabTexture;
            half _Brightness;
            v2f vert (appdata v) {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.grabPos = ComputeGrabScreenPos(o.vertex);
                return o;
            }
            fixed4 frag (v2f i) : SV_Target {
                return tex2Dproj(_GrabTexture, i.grabPos) * _Brightness;
            }
            ENDCG
        }
    }
}

可以看到使用了GrabPass { “_GrabTexture” },名字与之前的毛玻璃+亮度方式的shader的”_GrabTexture”是一直的名字。

那么看看Frame Debugger的绘制过程

三次Draw Mesh,但只有一次Grab RenderTexxture,而且Draw Mesh BrightnessSphere与后面两次BlurSphere的Shader是不一样的,只不过他们使用的GrabPass {“Name”}中的Name是一样的。

注意性能

从Frame Debugger中的GrabPass的处理过程,显示的是:Grab RenderTexture,注意后面是RenderTexture

注意都是猜测,具体调用底层的渲染API这个需要使用一个分析工具才能确定

而这个过程是很费(耗费性能)的,所以我们尽量能用GrabPass {“Name”} 就不用GrabPass{ }

刚开始我以为是RT的方式,后来发现一篇文章,虽然不是将GrabPass的,是将一般后效使用的MonoBehaviour类的回调OnRenderImage的方法下的Graphics.Blit,也是挺卡的,在一般的手机上。

这篇文章中有讲解:在使用Mali系列的Android手机。他用Mali Graphics Debugger看到底层渲染API的调用。

图中可以看到Unity的Profiler中,有显示调用的是RenderTexture.GrabPixels

中MGD(Mali Graphics Debugger)中查看底层API

在glReadPixels的最后个参数不为空,则表示数据从显存传输到系统内存,从CPU到GPU的逆向传输,这是非常缓慢的过程,并且是阻塞模式。

2020.03.15 更新,在看到unity 的SIGGRAPH2011的某个文档有说明:
GrabPass {“name”}
• new in 3.4: only copies the color buffer once per frame (_Grab is shared)

所以确定,调用的就是glReadPixel来读取ColorBuffer的像素的。

在FORCE FIELD EFFECT那部分有说明,文档:SIGGRAPH2011 Special Effect with Depth.pdf
如果多年后,下载不了,链接无效了,可以点击这里(Passworld:cmte)下载(我收藏到网盘了)

原文地址

移动端高性能图形开发 – 移动端GPU架构探究(转)

提要

随着移动端上引擎的渲染特性如HDR,PBR,SSR,完整的后处理管线等等不断的增加,直接照搬端游方案很容易翻车,很多时候需要针对移动端做适配,这时候只有对移动端硬件底层做充分的了解才能得出最优解。 本文从引擎开发者角度详细阐述一下对于移动端GPU架构的理解。

从IMR说起

IMR常用于PC,主机,笔记本等设备。最大的特点就是Primitive提交之后直接进行绘制, 如下如所示

这个和RTR 上的Pipeline是一致的。

硬件层面的渲染流程见下图,

IMR中的内存访问 
首先确认的是,FrameBuffer是一直存储在SystemMemory上的,每个Primitive的渲染都会读取和写入DepthStencil,SceneColor,这些都是放在System Memory上的,读取System Memory(也叫Main Memory,也叫DDR,也叫DRAM)上的数据计算完之后又写入主存。

假设FrameBuffer是存储在主存上的一段连续内存,那么每个三角形在绘制的时候其实是一种随机的内存访问,这种随机即使在加入Cache机制之后还是会引起很大的性能消耗。

IMR的优势和劣势 
优势 – 简单顺畅的流程保证了在单个Primitive渲染流程中没有额外的中间结果存储。

劣势 – 带宽消耗。

假设渲染一个1440p的画面,使用32bit的SceneColor,32bit的Depth/stencil,那整体需要的内存大小为30m,如果整个FrameBuffer都要同时随机访问的话,即使有Cache机制,也会触发大量的从System Memory到Cache的读取以及从Cache到System Memory的写入。

移动端方案 – TBDR

移动端硬件设计的目的 相对于桌面gpu单纯追求更高的帧率,移动端的方案则需要同时考虑更低的发热,更小的功耗,更高的性能,并在他们之中寻找平衡点。所以移动端硬件设计方案最重要就是尽可能的减少带宽消耗。所以就有了TBDR(Tiled Based Deferred Rendering)。

整个FrameBuffer被划分成了多个块,称之为Tile,分Tile渲染,每次渲染一个Tile的时候,将当前Tile所影响的Primitive都取出来,进行对应的三角形变换,着色,深度测试后之后,将结果再写回主存,

AMD在早年的分享中做了一个实验,同样渲染两个三角形所产生带宽消耗对比

硬件上和PC有两个区别:

1、显存和系统内存共用,GPU和内存直接打交到代价很大。

2、GPU上封装了一块很小的高速存储空间,称之为On-chip memory(也叫Tile Memory,也叫 on-chip framebuffer, Tile Buffer之类的),这块内存在渲染的第二阶段就会使用这部分的内存,大小因不同的GPU设计而不同,最小可能只覆盖 16 × 16 pixel。

当然有些厂商还做了多级缓存机制,比如最新的apple芯片,除了每个Shader Core都拥有自己的L1 Cache之外,还增加了GPU Last Level Cache,进一步减小了GPU与主存传递数据的代价。

详解TBDR渲染流程

完整的pipeline可以参考下图


伪代码如下

# Phase one
for draw in renderPass:
    for primitive in draw:
        for vertex in primitive:
            execute_vertex_shader(vertex)
        if primitive not culled:
            append_tile_list(primitive)
# Phase two
for tile in renderPass:
    for primitive in tile:
        for fragment in primitive:
            execute_fragment_shader(fragment)

上面的流程可以分为两个Phase – Tiling Phase和Render Phase。

Tiling Phase

在绘制Tile的时候需要只要当前Tile有哪些Primitive参与绘制,这个需要提前算好,这个计算的过程就叫Tilling(也叫Binning)。

Tilling Phase做三件事

1、将当前ViewPort划分为多个Tile,

2、执行VertexShader,三角形都变换到屏幕空间

3、计算Primitive会影响到哪些Tile,并将结果存储到主存上去。

Tilling计算的结果会存储到主存去,称之为Intermediate store(也有叫 polygon lists,也有叫Frame Data,也有叫 parameter buffer, 也叫Geometry Work Set),后面在分Tile渲染的时候会从主存load这个数据,但是这个代价就小很多了,不过也要注意顶点数量,不要让这个buffer过于膨胀。

下面有个简单的示意动画

Rendering Phase

Tiling之后GPU就对依次渲染这些Tiles,每个GPU Core每次渲染一个Tile,Core越多,同时能渲染的Tile就越多。

几个重要的点单独拎出来说一下

硬件层面的RenderPass

简单的说,就是一次渲染管线的执行。一次RenderPass会将渲染结果存储到FBO的Attachments里面,每个Attachment都需要在Tile上初始化(Load Action),并且有可能需要写回到SystemMemory(SaveAction)。

Tile Memory Load Store Action

对于OnChip和System Memory之间内存的拷贝,我们通常称之为Resolve,甚至还有Light weight resolve(从OnChip到System Memory)和Heavy weight resolve(从System Memory到OnChip 再写回到System Memory)的说法,另外MSAA里面也有关于resove的说法,所以…

Resolve 的具体含义得根据上下文来确定。

不过现在多了一种说法就是Load Action 和Store Action。 如果是TileMemory到SysemMemory的内存写入,指的就是Store Action为Store。

Load Action 有三种,DontCare,Load, Clear。 Store Action 有三种, Store, DontCare,storeAndMultisampleResolve, MultisampleResolve. 每种Action 的详细说明请参考苹果的文档Load and Store Actions

如果想要优化流水线的性能,就一定要注意设置好每个RenderTarget的Load Action 和 Store Actions。

比如,深度和Stencil通常只有在Rasterize阶段才会使用,所以直接放到了Onchip上, Store Actions设置为DontCare这样就不用把结果写入主存,省下了大量的带宽。

Resource Storage Mode

通常用StorageMode来表示内存中的对象的被CPU和GPU的访问模式,通常有Shared,Private,Memoryless三种,如下图所示

Shared – CPU 和GPU都可以访问,这类资源通常由CPU创建并更新。

Private – 存在SystemMemory上,只有GPU可以访问,通常用于绘制 render targets,Compute Shader存储中间结果或者 texture streaming.

Memotyless – 存在TileMemory, 只有当前Tile可以访问,用完就会被刷新掉,比如Depth/Stencil Buffer 在 iOS 上对于所有的不需要 resolve 的 rt(或 store action 设置为 don’t care)都应该设置为 memoryless,比如上面说到的Depth和Stencil。

Tiled Base Deferred Render中的Defered

其实这里有两个Deferred,一个是指VS之后不立即进行PS着色,所有的厂商的移动端芯片都是如此。 另一个Deferred是由Power VR以及苹果的芯片独占,这个Deferd指的是shading操作是在所有像素都经过可见性检测之后才进行的,这个可见性判定的操作称之为HSR.下图是详细的HSR流程。

其实每个厂商都有一些相关的消除Overdraw的算法,比如Mali的Forward Pixel Kill (FPK),比如高通的A Low Resolution Z (LRZ) ,具体算法以及原理可以看最后的链接。

TBDR的优势和劣势

1、带宽上的节省

2、由于OnChip Memory的存在,一些不需要resolve出来的信息可以只存在OnChip中,可以节省System Memory。

3、分Tile 渲染对于Texture Cache 更加友好

1、屏幕尺寸越大,划分的Tile数量越多,生成的Geometry WorkSet越大。

2、Tilling Phase是完全多出来的,打断了流水线的执行,引入了latency。

Performance Tuning

主要介绍一下Profile工具,之前有写过一篇高级图形调试优化技巧 – XCode篇,有兴趣可以点开看看,不过最新版本的xcode又出了很多新的功能,最好是去wwdc的视频学习一下。

在XCode截帧之后,每个RenderPass都单独标记出来,并且每个Attachment的Load Action和Save Action)以及Memory Type都有标注出来,还可以看到当前pass的带宽消耗以及当前帧的总带宽消耗。

Android下可以使用Renderdoc,不过API需要是Vulkan,截帧之后就可以看到每个pass以及对应的load store action.

当然还有其他厂商比如Arm和高通提供的工具,总体来说,还是Xcode好用一些。

小结

移动端硬件发展至今,大的渲染框架还是TB(D)R这一套,但是随着硬件制程以及技术迭代,移动端硬件能提供的性能空间也在飞速发展,各种移动端的渲染技术也是层出不穷,如何合理地使用与分配这些性能空间算是引擎开发者的必备技能了。

另一方面,移动端和桌面端的硬件区别也将会越来越小,不用说几年前的switch掌机,还是去年苹果发布的arm架构的M1芯片,一些桌面端的GPU甚至也利用TBR来做性能优化了, 称之为partially-tile-based rendering

移动端也有在某些情况下切换IMR渲染的技术,比如高通的FlexRender。

后面还会继续针对移动端高性能图形开发写几篇东西,大家敬请期待。

Reference

Tile-Based Rendering https://developer.arm.com/solutions/graphics-and-gaming/developer-guides/learn-the-basics/tile-based-rendering Performance Tuning for Tile-Based Architectures – OpenGL Insights Chapter.23 ARM’s

Mali Midgard Architecture Explored – https://www.anandtech.com/show/8234/arms-mali-midgard-architecture-explored/7

Next-Gen Tile-Based GPUs – http://developer.amd.com/wordpress/media/2013/01/gdc2008_ribble_maurice_TileBasedGpus.pdf

GPU Framebuffer Memory: Understanding Tiling – https://developer.samsung.com/galaxy-gamedev/resources/articles/gpu-framebuffer.html

Best Practices For Mobile

Principles of High Performance – https://developer.arm.com/solutions/graphics-and-gaming/developer-guides/learn-the-basics/principles-of-high-performance A New Software Based GPU Framework Evgeny Miretsky 2013

On NVIDIA’s Tile-Based Rendering – https://www.techpowerup.com/231129/on-nvidias-tile-based-rendering

Tile-based Rasterization in Nvidia GPUs – https://www.realworldtech.com/tile-based-rasterization-nvidia-gpus/

Understanding GPU Family 4 – https://developer.apple.com/documentation/metal/gpu_features/understanding_gpu_family_4

How GPU Works – https://cs184.eecs.berkeley.edu/sp19/lecture/23-51/how-gpus-work

PowerVR performance tips for Unreal Engine 4 – https://www.imaginationtech.com/blog/powervr-performance-tips-for-unreal-engine-4/

Load and Store Actions – https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/MTLBestPracticesGuide/LoadandStoreActions.html

Killing Pixels – A New Optimization for Shading on ARM Mali GPUs – https://community.arm.com/developer/tools-software/graphics/b/blog/posts/killing-pixels—a-new-optimization-for-shading-on-arm-mali-gpus

Visibility processing – https://developer.qualcomm.com/docs/adreno-gpu/developer-guide/gpu/overview.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

Unity2019 VSCode 编辑器找不到UnityEngine.UI 引用解决方案

原因

在Unity2019.4 把UnityEngine.UI.dll 等一些常用dll改路径了
新的路径在: C:/Program Files/Unity/Editor/Data/Resources/PackageManager/ProjectTemplates/libcache/com.unity.template.universal-7.3.1/ScriptAssemblies/UnityEngine.UI.dll

解决方案

手写路径 导入

在VSCode中找到工程的Assembly-CSharp.csproj,相应位置输入如下:

<Reference Include="UnityEngine.UI">
    <HintPath>C:/Program Files/Unity/Editor/Data/Resources/PackageManager/ProjectTemplates/libcache/com.unity.template.universal-7.3.1/ScriptAssemblies/UnityEngine.UI.dll</HintPath>
</Reference>

保存即可。