文章目录
  1. 1. 前言
  2. 2. Mask
    1. 2.1. Mask组件
      1. 2.1.1. Stencil Buffer
      2. 2.1.2. Mask+UI组件
      3. 2.1.3. Mask+粒子特效
        1. 2.1.3.1. Mask+自带ParticleAdditive Shader
        2. 2.1.3.2. Mask+修改ParticleAdditive Mask Shader
        3. 2.1.3.3. 仅使用自带UI Default Shader
        4. 2.1.3.4. ParticleEffectForUGUI
          1. 2.1.3.4.1. ParticleEffectForUGUI导入
          2. 2.1.3.4.2. 扩展粒子特效Shader支持Stencil和Clip
          3. 2.1.3.4.3. 制作粒子特效GameObject
          4. 2.1.3.4.4. UIParticle缩放
          5. 2.1.3.4.5. UIParticle遮罩
          6. 2.1.3.4.6. UIParticle层级
          7. 2.1.3.4.7. UIParticleAttractor
    2. 2.2. RectMask2D组件
      1. 2.2.1. 将粒子特效重定向到CanvasRenderer里去渲染
      2. 2.2.2. 手动传递RectMask2D裁剪区域到粒子Shader
  3. 3. 3D和2D混合显示及决方案
    1. 3.1. Extra 3D Model Camera
    2. 3.2. 3D Camera + Render Texture
  4. 4. 学习总结
  5. 5. Github
  6. 6. Reference

前言

Unity游戏开发过程中,2D UI上常常会通过添加Mask或者RectMask2D组件进行UI遮罩显示。本章是为了深入学习理解Mask和RectMask2D原理相关知识,思考3D粒子,模型和UI混合显示方案

Mask

Mask是指通过遮罩控制可见显示区域的一种遮罩显示。

UGUI里常见的遮罩有两种:

  1. Mask
  2. Rect2DMask

虽然都是实现Mask效果,但两个组件的底层遮罩实现原理却大不相同,接下来让我们深入Mask和Rect2DMask底层实现,深入理解Mask底层的相关知识,会后续3D和2D混合显示遮罩打下基础。

Mask组件

在深入了解Mask组件之前,我们需要了解一下什么是Stencil Buffer(模板缓存),Stencil Buffer是实现遮罩的底层渲染原理。

Stencil Buffer

The stencil buffer stores an 8-bit integer value for each pixel in the frame buffer. Before executing the fragment shader for a given pixel, the GPU can compare the current value in the stencil buffer against a given reference value. This is called a stencil test. If the stencil test passes, the GPU performs the depth test. If the stencil test fails, the GPU skips the rest of the processing for that pixel. This means that you can use the stencil buffer as a mask to tell the GPU which pixels to draw, and which pixels to discard.

从上面的介绍可以看出,Stencil Buffer(模板缓存)不同于深度Buffer和颜色Buffer,不是用来存储深度或颜色信息的,而是一个每个像素分配8bit(0-255)用来存储Stencil比较值的地方。如果一个pixel通不过Stencil Test(类似Depth Test,但是是用来比较Stencil Buffer值的方式),那么这个pixel将不会再走后续渲染流程(Depth Test)。

Unity Stencil有很多参数,这里调几个重要的参数介绍:

  • comparisonOperation(Stencil值比较方式)

    StencilTestCompareValue

    Stencil Test比较公式:

    (ref & readMask) comparisonFunction (stencilBufferValue & readMask)

  • passOperation(Stencil值通过替换方式)

    StencilTestReplaceValue

  • ref(引用参考值)

    用于Stencil值比较或Stencil Pass后值替换等

  • ReadMask(Stencil比较掩码)

    用于Stencil Test时值比较过滤,从上面的Stencil Test公式可以看到Ref和Stencil Buffer Value值都与ReadMask与之后在进行比较的

  • WriteMask(Stencil写掩码)

    用于Stencil Test通过后passOperation值写入过滤,即与passOperation后的值进行比较过滤再写入Stencil Buffer

更多参数参考官网:

ShaderLab command: Stencil

Note:

  1. Stencil Test发生Depth Test之前,Aplha Test和fragment shader之后。顺序是:Alpha Test -> fragment shader -> Stencil Test -> depth test
  2. 模板缓冲可以用来制作物体的遮罩、轮廓描边、阴影、遮挡显示等等效果

Mask+UI组件

先让我们尝试一下Mask组件的使用:

使用Mask组件前:

BeforeMaskComponentUsing

使用Mask组件后:

MaskComponentUsing

AfterMaskComponentUsing

可以看到通过使用一个圆形遮罩我们成功的将√实现了圆形的遮罩显示,当同时右上角Status数据也可以看到,激活Mask给我们增加了1个Batches和2个SetPass calls,至于原因后续会讲到。

那么第一个疑问,我们想知道的是Mask组件式如何实现遮罩的了?

首先我打开FrameDebug看了下Image是实际绘制流程:

MaskFrameDebugDrawPreview

可以看到1个Mask和1张图绘制总共分为3步,绘制了3次Mesh。

打开Mask.cs源码,我们会看到两个跟材质挂钩的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/// Stencil calculation time!    
public class Mask : UIBehaviour, ICanvasRaycastFilter, IMaterialModifier
{
******

[NonSerialized]
private Material m_MaskMaterial;

[NonSerialized]
private Material m_UnmaskMaterial;

public virtual Material GetModifiedMaterial(Material baseMaterial)
{
if (!MaskEnabled())
return baseMaterial;

var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);
if (stencilDepth >= 8)
{
Debug.LogWarning("Attempting to use a stencil mask with depth > 8", gameObject);
return baseMaterial;
}

int desiredStencilBit = 1 << stencilDepth;

// if we are at the first level...
// we want to destroy what is there
if (desiredStencilBit == 1)
{
var maskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Replace, CompareFunction.Always, m_ShowMaskGraphic ? ColorWriteMask.All : 0);
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMaterial;

var unmaskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Zero, CompareFunction.Always, 0);
StencilMaterial.Remove(m_UnmaskMaterial);
m_UnmaskMaterial = unmaskMaterial;
graphic.canvasRenderer.popMaterialCount = 1;
graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);

return m_MaskMaterial;
}

//otherwise we need to be a bit smarter and set some read / write masks
var maskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit | (desiredStencilBit - 1), StencilOp.Replace, CompareFunction.Equal, m_ShowMaskGraphic ? ColorWriteMask.All : 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMaterial2;

graphic.canvasRenderer.hasPopInstruction = true;
var unmaskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit - 1, StencilOp.Replace, CompareFunction.Equal, 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
StencilMaterial.Remove(m_UnmaskMaterial);
m_UnmaskMaterial = unmaskMaterial2;
graphic.canvasRenderer.popMaterialCount = 1;
graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);

return m_MaskMaterial;
}
******
}

从源码可以看到,Mask组件实现了IMaterialModifier接口。

IMaterialModifier is Interface which allows for the modification of the Material used to render a Graphic before they are passed to the CanvasRenderer.

UGUI我们是通过Canvas统一进行绘制渲染的,从上面的介绍可以看出要实现IMaterialModifier允许我们在传递给CanvasRenderer绘制渲染UI(Graphic)之前进行材质处理,而IMaterialModifier.GetModifiedMaterial()正是这么个接口。

打开StencilMaterial.cs查看源码StencilMaterials.Add()接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/// <summary>
/// Add a new material using the specified base and stencil ID.
/// </summary>
public static Material Add(Material baseMat, int stencilID, StencilOp operation, CompareFunction compareFunction, ColorWriteMask colorWriteMask, int readMask, int writeMask)
{
if ((stencilID <= 0 && colorWriteMask == ColorWriteMask.All) || baseMat == null)
return baseMat;

if (!baseMat.HasProperty("_Stencil"))
{
Debug.LogWarning("Material " + baseMat.name + " doesn't have _Stencil property", baseMat);
return baseMat;
}
if (!baseMat.HasProperty("_StencilOp"))
{
Debug.LogWarning("Material " + baseMat.name + " doesn't have _StencilOp property", baseMat);
return baseMat;
}
if (!baseMat.HasProperty("_StencilComp"))
{
Debug.LogWarning("Material " + baseMat.name + " doesn't have _StencilComp property", baseMat);
return baseMat;
}
if (!baseMat.HasProperty("_StencilReadMask"))
{
Debug.LogWarning("Material " + baseMat.name + " doesn't have _StencilReadMask property", baseMat);
return baseMat;
}
if (!baseMat.HasProperty("_StencilWriteMask"))
{
Debug.LogWarning("Material " + baseMat.name + " doesn't have _StencilWriteMask property", baseMat);
return baseMat;
}
if (!baseMat.HasProperty("_ColorMask"))
{
Debug.LogWarning("Material " + baseMat.name + " doesn't have _ColorMask property", baseMat);
return baseMat;
}

for (int i = 0; i < m_List.Count; ++i)
{
MatEntry ent = m_List[i];

if (ent.baseMat == baseMat
&& ent.stencilId == stencilID
&& ent.operation == operation
&& ent.compareFunction == compareFunction
&& ent.readMask == readMask
&& ent.writeMask == writeMask
&& ent.colorMask == colorWriteMask)
{
++ent.count;
return ent.customMat;
}
}

var newEnt = new MatEntry();
newEnt.count = 1;
newEnt.baseMat = baseMat;
newEnt.customMat = new Material(baseMat);
newEnt.customMat.hideFlags = HideFlags.HideAndDontSave;
newEnt.stencilId = stencilID;
newEnt.operation = operation;
newEnt.compareFunction = compareFunction;
newEnt.readMask = readMask;
newEnt.writeMask = writeMask;
newEnt.colorMask = colorWriteMask;
newEnt.useAlphaClip = operation != StencilOp.Keep && writeMask > 0;

newEnt.customMat.name = string.Format("Stencil Id:{0}, Op:{1}, Comp:{2}, WriteMask:{3}, ReadMask:{4}, ColorMask:{5} AlphaClip:{6} ({7})", stencilID, operation, compareFunction, writeMask, readMask, colorWriteMask, newEnt.useAlphaClip, baseMat.name);

newEnt.customMat.SetInt("_Stencil", stencilID);
newEnt.customMat.SetInt("_StencilOp", (int)operation);
newEnt.customMat.SetInt("_StencilComp", (int)compareFunction);
newEnt.customMat.SetInt("_StencilReadMask", readMask);
newEnt.customMat.SetInt("_StencilWriteMask", writeMask);
newEnt.customMat.SetInt("_ColorMask", (int)colorWriteMask);
newEnt.customMat.SetInt("_UseUIAlphaClip", newEnt.useAlphaClip ? 1 : 0);

if (newEnt.useAlphaClip)
newEnt.customMat.EnableKeyword("UNITY_UI_ALPHACLIP");
else
newEnt.customMat.DisableKeyword("UNITY_UI_ALPHACLIP");

m_List.Add(newEnt);
return newEnt.customMat;
}

可以看到Mask在IMaterialModifies.GetModifiedMaterial()接口里是对UI的原始材质进行复制后,对Stencil(模板缓存)进行修改实现的遮罩效果。

结合首层Mask逻辑,我们来理一下遮罩原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// if we are at the first level...
// we want to destroy what is there
if (desiredStencilBit == 1)
{
var maskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Replace, CompareFunction.Always, m_ShowMaskGraphic ? ColorWriteMask.All : 0);
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMaterial;

var unmaskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Zero, CompareFunction.Always, 0);
StencilMaterial.Remove(m_UnmaskMaterial);
m_UnmaskMaterial = unmaskMaterial;
graphic.canvasRenderer.popMaterialCount = 1;
graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);

return m_MaskMaterial;
}

如果是第一层,stencilDepth值为0,目标模版缓存值(desiredStencilBit)为1。

  1. Unity Mask先添加了基于baseMaterial(原始配置的材质球)新裁剪材质球maskMaterial,将新材质球的Stencile(模版参考比较值)值改为1,将StencilOp的模板操作设置成Replace(模版缓存比较通过则替换成新的模板参考比较值)CompareFunction(模版比较方式)设置成Always(模板测试总是通过),根据是否显示Mask图形将ColorWriteMask(颜色写入FrameBuffer的Mask值)设置成255(所有颜色值都写入)或0(所有颜色值都不写入),至于Stencile的ReadMask和WriteMask(第一层默认传255)

  2. 通过第1步的新增裁剪材质球maskMaterial处理,当我们渲染Mask图形的时候,ColorWriteMask会决定将Mask图形颜色写入,反之不写入(这一步决定了mask图形是否显示)。

  3. 通过第1步的新材裁剪质球maskMaterial处理,因为模板比较是Always(总是通过),结合模版参考值1和模板操作方式Replace,此时渲染完Mask图形后Stencil Ref值应该为1

    AfterMaskGraphRenderStatus

  4. 接着创建unmaskMaterial材质球,并将其设置成遮罩Graphic.canvasRendererd的SetPopMaterial(个人理解是后续用于渲染完所有子对象后的额外材质球处理)用于取消Mask的相关设置,这一步发生在Mask图形所有子对象渲染完成时(从结果额外绘制了一个Mesh触发了unmaskMaterial逻辑来猜测的),后续详细介绍

  5. 接下来渲染遮罩图形的子对象tickImg

    MaskChildGraphicStencilSetting

    RenderMaskChildGraphicStatus

    可以看到遮罩图形的子对象tickImg的模板相关设置是Stencil Ref(模版参考比较值)为1StencilOp的模板操作是Keep(保持原模板缓存值),StencilComp的模板比较操作是Equal(模版值和模板参考值相同才通过模板测试)Stencil Write Mask(模版缓存值写入时的Mask值)值为0(不允许写入模板缓存值)Stencil Read Mask(模版缓存读取时的Mask值)值为1(只允许读取第一位的模板值)ColorMask(颜色写入Mask值,8bit标识,15为所有颜色值写入)值为15

    我这里使用的是Unity自带的UI/Default Shader,这些模板值设置是发生在我给UI挂载对象时或者增删Mask组件时

    因此子对象渲染时发现模版缓存值为1,且自身设置模板参考比较值也为1,同时模板比较方法是Equal,从而实现只有在Mask图形区域类模板缓存值为1的部分满足显示,从而只有Mask图形区域内的子对象显示部分通过了模板缓存测试,同时通过后因为StencilOp的模板操作是Keep所以模板缓存值不变依然是1

  6. 子对象渲染完成后,我们接着看第4步里unmaskMaterial的后续逻辑,unmaskMaterial的Stencil Ref(模版参考比较值)为1,StencilOp的模板操作是Zero(设置模板值到0),StencilComp的模板比较操作是Always(总是通过模板缓存测试),ColorMask为0(不写入任何颜色)

    UnmaskMaterialStatus

    因为maskMaterial和子对象渲染完成后,模版缓存里的值为1,此时unmaskMaterial的StencilComp是Always,StencilOp的模板操作是Zero,所以会将模板缓存里的值更新到0,同时因为ColorMask为0所以这一次unmaskMaterial的额外Mesh渲染绘制不会显示任何东西。

Note:

  1. Mask实现遮罩效果底层原理是通过Stencil(模板缓存)

Mask+粒子特效

Mask+自带ParticleAdditive Shader

在了解完Mask组件的常规UI遮罩原理后,接下来让我们尝试下UI上放入粒子特效

ParticleAdditiveInspector

可以看到我将Unity自带的Particle Additive的Shader导入工程并创建了一个材质球使用到了粒子系统身上,同时为了确保粒子特效显示层级在UI上,还专门设置了Sorting Layer ID为Default,Order in Layer为101(因为MainUI节点上挂了Canvas且设置的Sorting Layer为Default,Order in Layer为100)

让我们看下Unity自带的Particle Additive的Shader代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Shader "Custom/Legacy Shaders/Particles/Additive" {
Properties {
_TintColor ("Tint Color", Color) = (0.5,0.5,0.5,0.5)
_MainTex ("Particle Texture", 2D) = "white" {}
_InvFade ("Soft Particles Factor", Range(0.01,3.0)) = 1.0
}

Category {
Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" }
Blend SrcAlpha One
ColorMask RGB
Cull Off Lighting Off ZWrite Off

SubShader {
Pass {
******
}
}
}

可以看到默认的Particle Additive的Shader是没有带模板缓存相关的Shader代码支持的。渲染结果如下:

ParticleAdditiveShowResult

不出意外Particle Additive的Shader的粒子特效超出了Mask显示范围。

Mask+修改ParticleAdditive Mask Shader

接下来我们尝试改造Particle Additive的Shader,复制一份改名Particle Additive Mask同时创建一个对应的材质球(ParticleAdditiveMaskMat),赋值到particleSystem上。

ParticleAdditiveMaskInspector

为了匹配Mask的模版缓存判定,我将Stencil ID设置成1(模版缓存参考比较值),Stencil Comparison设置成3(Equal),Stencil Operation设置成0(Keep),ColorMask设置成15(所有颜色写入),这些设置是为了在Mask下去比较模板缓存值1的才通过,从而实现超出Mask区域部分不通过不绘制

让我们看看改造后的显示结果:

ParticleAdditiveMaskShowResult

结果发现粒子特效啥都没显示,让我们看看FrameDebugger是怎么回事。

ParticleAdditiveMaskFrameDebugger

从FrameDebugger可以看到粒子特效的渲染和常规UI的渲染流程不一样,因为粒子特效是当做3D物体渲染的,所以他的渲染顺序不想UI子节点图形没有在Canvas的渲染流程里,而是Canvas渲染完成后才通过Draw Dynamic的方式绘制的。

还记得我们前面说的Mask组件在绘制完成后通过unmaskMaterial将模板缓存里的值已经修改成0了吗?

这个模版缓存值0也就是粒子特效不显示的原因,因为粒子特效Particle Additive Mask我们设置的Stencil ID(模版参考比较值)为1,Stencil Comparion为3(Equal),也就意味着粒子特效通不过模板缓存测试所以什么都不显示了。

仅使用自带UI Default Shader

unmaskMaterial这个流程是Mask流程里自带的,不修改源码是无法修改的,所以目前看来想通过粒子特效支持模板缓存功能从而通过Mask遮罩显示的方案是行不通的。

既然我们已经知道模板缓存是Mask实现遮罩的核心原理,那么我们如果不使用Mask组件,直接导入自带的UI Default Shader,通过直接创建一个非Mask的UI Default材质,一个负责Mask的UI Default Mask材质和一个支持被UI Default Mask遮罩后显示的UI Default Masked材质来实现模版遮罩功。

UIDefaultMatInspector

UIDefaultMaskMatInspector

UIDefaultMaskedMatInspector

可以看到三份材质球都是使用功能的UI Default Shader,但为了Mask不同情况下正确显示,分别设置了不同的Stencil ID,Stencil Comparison,Stencil Operation值。UIDefaultMat用于不需要管遮罩时,UIDefaultMaskMat用在当做遮罩(相当于Mask挂载的情况)时,UIDefaultMaskedMat用在在遮罩里需要被遮罩时。

为了方便查看层级显示和遮罩效果,我创建了一个红色的使用UI Default Mat的最底层图和一个黄色的使用UI Default Mat的最顶层图(同时添加了Canvas设置了order高于特效显示order)

来看看图片和特效相关的层级:

UIDefaultMaskSceneAndHierachy

来看看运行时的效果:

UIDefaultMaskGame

可以看到无论是普通背景和前景图还是被遮罩的图还是粒子特效,通过赋予对应的UI Default Shader的材质球成功实现了Mask遮罩效果。

让我们来看看Frame Debugger是什么情况:

UIDefaultMaskFrameDebugger

可以看到带遮罩的相关图形显示在第一个Canvas.RenderSubBatch里,因为使用Mask组件,所以没有产生unmaskMaterial的流程,所以第一个Canvas.RenderSubBatch最后一个Draw Mesh是被遮罩的那个勾选图形,因为设置的是UI Default Masked Mat(Stencil ID为1,Stencil Comparison为3(Equal),Stencil Operation为0(Keep))所以成功通过了模板缓存并遮罩显示。

接着看看粒子特效显示是什么情况:

UIDefaultMaskParticleSystemFrameDebugger

粒子特效的显示和被遮罩的勾选图形原理和参数一致,紧接着第一个Canvas.RenderSubBatch之后渲染,所以也成功遮罩显示了。

接着看看defaultFrontImg前景图是什么情况:

UIDefaultMaskSecondCanvasFrameDebugger

可以看到因为我给defaultFrontImg添加了额外的Canvas并设置了比特效Order还高的Order,所以单独进行了一次Canvas.RenderSubBatch绘制,即第二个Canvas.RenderSubBatch绘制通过Stencil Ref值为0和Stencil Comparison为8(Always)成功将第二个Canvas渲染绘制的模板缓存值修改成0了。

UIDefaultMaskFrontImgFrameDebugger

可以看到我给defaultFrontImg设置的UI Default Mat(Stencil ID为0,Stencile Comparison为8(Always),Stencile Operation为0(Keep))完美符合了第二个Canvas.RenderSubBatch将模板值修改成0后的显示逻辑,所以成功绘制出来了。

总结:

  1. 虽然通过制作不同的UI Default材质球可以实现遮罩不同的使用情况,但游戏开发过程中遮罩情况可能是动态变化,无法提前预知哪些是用于遮罩,哪些是在遮罩内,哪些是在遮罩外,所以这种方案虽然能实现遮罩效果但并没有实战使用价值。

ParticleEffectForUGUI

接下来让我们看看Github上最强特效和UI显示层级解决方案:

ParticleEffectForUGUI

特点介绍:

  • Sortable: Sort particle effects and other UI elements by sibling index.
  • Maskable: Supports Mask or RectMask2D.
  • No extra components required: No need for an additional Camera, RenderTexture, or Canvas.
  • Trail module support: Fully supports the Trail module.
  • CanvasGroup alpha support: Integrates with CanvasGroup alpha.
  • No allocations: Efficiently renders particles without allocations.
  • Any canvas render mode support: Works with overlay, camera space, and world space.
  • Any Render pipeline support: Compatible with Universal Render Pipeline (URP) and High Definition Render Pipeline (HDRP).
  • 省略

从上面介绍可以看到ParticleEffectForUGUI能将特效在UI排序显示,无需任何Camera,RenderTexture或者Canvas。支持CanvasGroup的Alpha,支持Canvas的各种Render Mode。兼容URP,HDRP等。

ParticleEffectForUGUI导入

我们通过Package Manager选择Git地址(注意#后面的数字是版本号)导入:

1
https://github.com/mob-sakai/ParticleEffectForUGUI.git#4.11.4

Note:

  1. 导入完成后如果想直接使用源码版本,可以直接从Package下拷贝到Asset目录下使用,然后删除Package Manager里的导入库即可。
扩展粒子特效Shader支持Stencil和Clip

粒子特效的Shader要像UI Default Shader一样支持UI的Mask和Clip功能。

Particle Add UI Mask.Shader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
Shader "Custom/Legacy Shaders/Particles/Additive UI Mask" {
Properties {
******

// #### required for Mask ####
_StencilComp ("Stencil Comparison", Float) = 8
_Stencil ("Stencil ID", Float) = 0
_StencilOp ("Stencil Operation", Float) = 0
_StencilWriteMask ("Stencil Write Mask", Float) = 255
_StencilReadMask ("Stencil Read Mask", Float) = 255

_ColorMask ("Color Mask", Float) = 15

[Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0
}

Category {
Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" }

// #### required for Mask ####
Stencil
{
Ref [_Stencil]
Comp [_StencilComp]
Pass [_StencilOp]
ReadMask [_StencilReadMask]
WriteMask [_StencilWriteMask]
}

Blend SrcAlpha One
// #### required for Mask ####
ColorMask [_ColorMask]
Cull Off Lighting Off ZWrite Off

SubShader {
Pass {

******

#include "UnityCG.cginc"
// #### required for RectMask2D ####
#include "UnityUI.cginc"
#pragma multi_compile __ UNITY_UI_CLIP_RECT
#pragma multi_compile __ UNITY_UI_ALPHACLIP

******

struct v2f {
******

// #### required for RectMask2D ####
float4 worldPosition : TEXCOORD1;

UNITY_FOG_COORDS(2)
#ifdef SOFTPARTICLES_ON
float4 projPos : TEXCOORD3;
#endif
UNITY_VERTEX_OUTPUT_STEREO
};

float4 _MainTex_ST;
// #### required for RectMask2D ####
float4 _ClipRect;

v2f vert (appdata_t v)
{
******

// #### required for RectMask2D ####
o.worldPosition = v.vertex;

******

return o;
}

UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);
float _InvFade;

fixed4 frag (v2f i) : SV_Target
{
******

// #### required for RectMask2D ####
#ifdef UNITY_UI_CLIP_RECT
col.a *= UnityGet2DClipping(i.worldPosition.xy, _ClipRect);
#endif

#ifdef UNITY_UI_ALPHACLIP
clip(col.a - 0.001);
#endif

col.rgb *= col.a;

return col;
}
ENDCG
}
}
}
}

核心是像UI Default Shader里加入了UNITY_UI_CLIP_RECT和UNITY_UI_ALPHACLIP以及Stencil模板相关的定义。

Note:

  1. 注意定义v2f的worldPosition : TEXCOORD1时会占用TEXCOORD1,后续UNITY_FOG_COORDs(2)和projPos:TEXCOORD3都得顺势往后延
制作粒子特效GameObject

第1种情况,从0制作粒子特效:

  1. 创建UIParticle GameObject

    CreateEmptyUIParticleGameObject

  2. 在UIParticle GameObject节点下只做粒子特效

  3. 做完粒子特效后点击UIParticle面板上的Refresh按钮

    UIParticleRefreshButton

  4. 保存UIParticle GameObject当做特效使用

第二种情况,在已有的特效GameObject上制作粒子特效:

  1. 对已有粒子特效GameObject右键创建UIParticle

    CreateUIParticleFromGameObject

  2. 保存UIParticle GameObject当做特效使用

Note:

  1. 通过ParticleEffectForUGUI制作的粒子特效我们不再需要手动调整Order,我们可以像调整UI节点一样放到对应位置即可
  2. 要想UIParticle制作的特效支持Mask需要勾选UIParticle的Maskable
UIParticle缩放

UIParticle的显示大小根据UIParticle的AutoScalingMode设置不同而决定因素不同。UIParticle.scale是UIParticle提供控制粒子特效显示大小的一个参数。

粒子最终渲染尺寸的计算公式如下:

最终尺寸 = Scale3D值 × Canvas缩放因子 × 粒子系统自身缩放 × 父级UI元素缩放

UIParticle.AutoScalingMode是解决Canvas分辨率适配的核心参数,有三种模式:

看网上的说法Position Mode是Absolute或者Relative对于AutoScalingMode.Transform还是AutoScalingMode.UIParticle选择有影响,目前未做测试。

个人目前想法结论如下:

AutoScalingMode推荐UIParticle,制作粒子特效的Simulation Space推荐Local,UIParticle.scale创建时默认是10所有特效制作时基于scale修改成1的标准来做,这样我们设置UIParticle.scale缩放时就是相对1的比例来考虑(这样更符合类似Transform Scale缩放理念的设置)

Note:

  1. UIParticle.scale无论在任何模式下都会对粒子特效的最终显示大小都有影响
UIParticle遮罩

Mask遮罩,就像常规Mask遮罩UI对象一样,将制作的UIParticle GameObject放到对应节点位置即可。

ParticleEffectForUGUIMaskInspector

ParticleEffectForUGUIMaskShowResult

我们结合Mask遮罩来看看UIParticle是如何实现粒子特效支持Mask显示的。

首先看看粒子特效在FrameDebugger里是如何绘制的:

ParticleEffectForUGUIMaskFrameDebugger

可以看到粒子特效这一次没有在被当做3D对象单独绘制而是在Canvas.RenderSubBatch流程里绘制且绘制时机是在unmaskMaterial清除模板缓存值之前,我想这就就是为什么UIParticle绘制的粒子特效为什么能支持UI Mask和RectMask2D的原因。

那么UIParticle是如何实现将UI粒子特效当做UI对象在Canvas里绘制的了?

首先UIParticle是取消了ParticleSystem的Renderer组件激活(避免3D粒子特效的渲染)

ParticleEffectForUGUICancelPSRenderer

然后我们来看看UIParticle.cs的代码定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/// <summary>
/// Render maskable and sortable particle effect ,without Camera, RenderTexture or Canvas.
/// </summary>
[Icon("Packages/com.coffee.ui-particle/Editor/UIParticleIcon.png")]
[ExecuteAlways]
[RequireComponent(typeof(RectTransform))]
[RequireComponent(typeof(CanvasRenderer))]
public class UIParticle : MaskableGraphic, ISerializationCallbackReceiver
{
******

private readonly List<UIParticleRenderer> _renderers = new List<UIParticleRenderer>();
private Camera _bakeCamera;

protected override void UpdateMaterial()
{
}

/// <summary>
/// Call to update the geometry of the Graphic onto the CanvasRenderer.
/// </summary>
protected override void UpdateGeometry()
{
}

******
}

可以看到UIParticle组件添加时强制添加了CanvasRenderer组件的,同时UIParticle继承了MaskGraphic

CanvasRenderer组件是UIParticle融入到Canvas渲染流程里的必要组件

MaskGraphic是UIParticle获取Canvas UI渲染能力和遮罩支持的关键

UIParticle通过重写MaskGraphic.UpdateMaterial和MaskGraphic.UpdateGeometry方法避免任何渲染和材质相关的逻辑。UIParticle真正的渲染逻辑是通过**UIParticleRenderer(也继承至MaskableGraphic)**类完成的,也就是UIParticle._renders这个成员对象。

核心渲染流程方法是UIParticle.UpdateRenderers(),UIParticle.UpdateRenderers()方法是在UIParticleUpdater.cs里通过注入Canvas.onAfterCanvasRebuild(在Canvas渲染前调用)流程调用的

UIParticle.UpdateRenderers()会先收集之前通过UIParticle面板Refresh按钮搜集到的所有粒子特效,创建相关UIParticleRenderer对象(这里就是UIParticle._renders构建的地方)

然后UIParticle通过GetBakeCamera()方法获取到当前绘制Canvas的UICamera,然后UICamera传递调用UIParticleRenderer.UpdateMesh(BakeCamera)方法

UIParticleRenderer.UpdateMesh()方法里,通过调用ParticleSystemRenderer.BakeMesh()方法传入指定摄像机,将通过该UICamera渲染的Mesh保存到CombineInstance类数据里

然后将所有烘焙得到的CombineInstance的Mesh数据合并到一个mesh里(workerMesh.CombineMeshes())

最后将烘焙的bakeMesh数据设置到UIParticleRenderer.canvasRenderer.SetMesh()的Mesh里,然后UIParticleRenderer通过canvasRenderer参与到Canvas的绘制流程里,从而实现了粒子特效转换成UI Mesh参与到Canvas绘制流程里的过程。

那么UIParticleRenderer是如何实现支持UGUI的Mask和RectMask2D遮罩功能的了?

UIParticleRenderer.GetModifiedMaterial()里调用了base.GetModifiedMaterial(),接下来让我们看看内部的代码实现:

MaskableGraphic.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/// <summary>
/// See IMaterialModifier.GetModifiedMaterial
/// </summary>
public virtual Material GetModifiedMaterial(Material baseMaterial)
{
var toUse = baseMaterial;

if (m_ShouldRecalculateStencil)
{
var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0;
m_ShouldRecalculateStencil = false;
}

// if we have a enabled Mask component then it will
// generate the mask material. This is an optimisation
// it adds some coupling between components though :(
Mask maskComponent = GetComponent<Mask>();
if (m_StencilValue > 0 && (maskComponent == null || !maskComponent.IsActive()))
{
var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMat;
toUse = m_MaskMaterial;
}
return toUse;
}

可以看到,通过获取当前节点的最上层Canvas获取到最近的m_StencilValue值,然后通过判定是否有Mask组件决定是否要触发StencilMaterial的材质球效果(Stencil ID值为(1<< m_StencilValue) - 1),StencilOp值为0(Keep),StencilComparison值为3(Equal),Stencil ReadMask值为(1 << m_StencilValue) - 1),Stencil WriteMask值为0)。也就是与模板Stencil缓存值相比一致就通过模板测试的maskMat。个人认为这也就是UIParticleRenderer支持Mask的原因。

那么UIParticle又是如何实现RectMask2D的遮罩支持的了?

核心是RectMask2D会在RectMask2D.PerformClipping()里遍历访问MaskableGraphic,并调用MaskableGraphic.SetClipRect(),让我们来看看MaskableGraphic.SetClipRect()的代码:

1
2
3
4
5
6
7
8
9
10
/// <summary>
/// See IClippable.SetClipRect
/// </summary>
public virtual void SetClipRect(Rect clipRect, bool validRect)
{
if (validRect)
canvasRenderer.EnableRectClipping(clipRect);
else
canvasRenderer.DisableRectClipping();
}

可以看到这里应该是通过canvasRenderer.EnableRectClipping()成功将Shader里的UNITY_UI_CLIP_RECT的Shader关键词激活了,后续应该是设置并传入了Shader里_ClipRect变量的裁剪区域值。

RectMask2D遮罩,就像常规RectMask2D遮罩UI对象一样,将制作的UIParticle GameObject放到对应节点位置即可。

ParticleEffectForUGUIRectMask2DInspector

ParticleEffectForUGUIRectMask2DShowResult

至此我们成功通关ParticleEffectForUGUI插件实现了特效在UI里的Mask和RectMask2D遮罩显示。

UIParticle层级

通过ParticleEffectForUGUI制作的特效,在处理UI层级问题时可以直接向处理UI节点一样,只要保证节点顺序就能保证显示层级。

ParticleEffectForUGUILayerInspector

ParticleEffectForUGUILayerShowResult

可以看到我制作的白色粒子特效夹在UI中间显示,绿色粒子特效在最顶层显示正确显示了

UIParticleAttractor

UIParticleAttractor脚本是ParticleEffectForUGUI提供的一个队粒子做额外动画的组件。

比如指定粒子按半径的方式移动到目标UIParticleAttrctor所在为止。

UIParticleAttractorInspector

UIParticleAttractorShowResult

可以看到粒子在运行指定延迟时间后按目标终点以指定路线方式(e.g. 直线,曲线等)移动。

效果挺神奇的,可以看出ParticleEffectForUGUI对粒子的控制做到了单个粒子级别。

RectMask2D组件

RectMask2D是通过CanvasRenderer的裁剪矩形(ClipRect)实现,底层是CPU裁剪顶点。

标准ParticleSystem渲染在Camera空间,走的是Renderer,不是UGUI的CanvasRenderer。

从上面的介绍可以看到正常流程粒子特效没走CanvasRenderer,是无法被RectMask2D裁剪的,如果想支持粒子被RectMask2D裁剪,有两种方案:

  1. 将粒子特效重定向到CanvasRenderer里去渲染
  2. 手动写代码将RectMask2D的裁剪区域传递到粒子特效Shader里去,然后写Shader代码去判定裁剪显示

将粒子特效重定向到CanvasRenderer里去渲染

此方案直接学习ParticleEffectForUGUI组件,参考前面的ParticleEffectForUGUI学习

手动传递RectMask2D裁剪区域到粒子Shader

第二个方案参考:

Rect Mask2D遮住特效

Particle Add Mask.shader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
Shader "Custom/Legacy Shaders/Particles/Additive Mask" {
Properties {
******

// #### required for RectMask2D ####
_ClipRect ("ClipRect", Vector) = (0,0,0,0)
_UNITY_UI_CLIP_RECT("UNITY_UI_CLIP_RECT", float) = 0
}

Category {
******

SubShader {
Pass {

******

// #### required for RectMask2D ####
#include "UnityUI.cginc"

// #### required for RectMask2D ####
float4 _ClipRect;
float _UNITY_UI_CLIP_RECT;

sampler2D _MainTex;
fixed4 _TintColor;

struct appdata_t {
float4 vertex : POSITION;
fixed4 color : COLOR;
float2 texcoord : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct v2f {
******

// #### required for RectMask2D ####
float4 worldPosition : TEXCOORD1;

UNITY_FOG_COORDS(2)
#ifdef SOFTPARTICLES_ON
float4 projPos : TEXCOORD3;
#endif
UNITY_VERTEX_OUTPUT_STEREO
};

float4 _MainTex_ST;

v2f vert (appdata_t v)
{
******

// #### required for RectMask2D ####
o.worldPosition = mul(unity_ObjectToWorld, v.vertex);

#ifdef SOFTPARTICLES_ON
o.projPos = ComputeScreenPos (o.vertex);
COMPUTE_EYEDEPTH(o.projPos.z);
#endif
o.color = v.color;
o.texcoord = TRANSFORM_TEX(v.texcoord,_MainTex);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}

UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);
float _InvFade;

fixed4 frag (v2f i) : SV_Target
{
******

// #### required for RectMask2D ####
//等效于 if(_UNITY_UI_CLIP_RECT == 1) col.a *= UnityGet2DClipping(i.worldPosition.xy, _ClipRect);
col.a *= pow(UnityGet2DClipping(i.worldPosition.xy, _ClipRect) + 1, _UNITY_UI_CLIP_RECT) - _UNITY_UI_CLIP_RECT;

return col;
}
ENDCG
}
}
}
}

Note:

  1. 注意定义v2f的worldPosition : TEXCOORD1时会占用TEXCOORD1,后续UNITY_FOG_COORDs(2)和projPos:TEXCOORD3都得顺势往后延
  2. 因为粒子特效默认不是在UI的CanvasRenderer模式下渲染的,所以__ UNITY_UI_CLIP_RECT和__ UNITY_UI_ALPHACLIP宏默认是不是起作用的

ParticleClip.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

/// <summary>
/// ParticleClip.cs
/// 用于粒子系统遮罩裁剪
/// </summary>
public class ParticleClip : MonoBehaviour
{
/// <summary>
/// RectMask2D裁剪区域
/// </summary>
[Header("RectMask2D裁剪区域")]
public Vector4 ClipRect;

/// <summary>
/// 子Renderer组件列表
/// </summary>
private Renderer[] mChildRenderers;

void Start()
{
var mask = GetComponentInParent<UnityEngine.UI.RectMask2D>();
if (mask == null)
{
return;
}

mChildRenderers = GetComponentsInChildren<Renderer>();
if(mChildRenderers == null || mChildRenderers.Length == 0)
{
return;
}

Vector3[] vector3s = new Vector3[4];
mask.rectTransform.GetWorldCorners(vector3s);
ClipRect = new Vector4(vector3s[0].x, vector3s[0].y, vector3s[2].x, vector3s[2].y);

foreach (var renderer in mChildRenderers)
{
var rendererMaterial = renderer.material;
if(rendererMaterial == null)
{
continue;
}
if(rendererMaterial.HasVector("_ClipRect"))
{
rendererMaterial.SetVector("_ClipRect", ClipRect);
}
if(rendererMaterial.HasFloat("_UNITY_UI_CLIP_RECT"))
{
rendererMaterial.SetFloat("_UNITY_UI_CLIP_RECT", 1);
}
}
}
}

RectMask2DAndParticleClipInspector

ParticleClipAdditiveMaskShaderInspector

可以看到通过ParticleClip.cs脚本,我成功将RectMask2D的裁剪区域数据和是否启用UNITY_UI_CLIP_RECT的数据传递到了Particle Add Mask.shader,让我们看看最终效果:

RectMask2DAndParticleClipShowResult

可以看到通过传递自定义RectMask2D裁剪区域以及粒子特效Shader支持裁剪区域显示判定,我们成功实现了支持RectMask2D的粒子特效遮罩显示

总结:

  1. 此方案虽然能实现粒子特效的RectMask2D的遮罩效果,但节点挂载情况无时无刻都在变化,此方案不适合放到实战里去运用,只做为学习理解RectMask2D的裁剪原理

3D和2D混合显示及决方案

有了前面的渲染顺序基础知识以及Mask相关知识,现在让我们再来看看游戏开发过程中遇到的3D物体和2D UI需要混合显示的问题该如何解决了?

通过前面的学习,特效和UI混合显示已经有了很好的方案:

ParticleEffectForUGUI

但ParticleEffectForUGUI并不支持模型的显示。

结合前面渲染顺序的学习,目前个人想到两种方案:

  1. Extra 3D Model Camera
  2. Extra Camera+Render Texture

Note:

  1. 3D在UI混合显示需要在Transparent Renderer Queue

Extra 3D Model Camera

额外3D模型摄像机照射模型,然后通过预设3D场景摄像机,UI摄像机,3D模型摄像机的Depth值来排序显示。

好处:

  1. 3D模型只需要计算2D映射到3D摄像机的坐标位置显示即可,然后再处理一下不同分辨率的模型大小动态计算解决分辨率显示大小不同意问题即可

坏处:

  1. 3D模型摄像机只能固定在UI摄像机上或者下显示,如果在UI摄像机下方还会被背景图遮挡,所以理论上3D模型摄像机只能设置在UI摄像机上方显示(会导致UI没法盖住3D模型)
  2. 跨UI显示时,老的3D模型需要额外处理,不然会穿透显示

坏处1这个问题目前没有解决方案。

针对坏处2的解决方案如下:

通过编写一个UIModelManager来统一管理UI 3D模型的创建和显示管理,将3D模型的显示和UI窗口绑定关联,在打开新UI时,只要绑定的UI不在最顶层,3D模型就统一隐藏不显示(比如把对应3D摄像机直接隐藏),反之显示

实战TODO

3D Camera + Render Texture

此方案思路是将3D摄像机单独照射后渲染到Render Texture上,然后通过UI上显示Render Texture的内容来实现模型在UI上的显示。

好处:

  1. 3D模型显示的层级问题很好解决,可以像UI一般夹层显示,不用担心任何夹层和跨UI打开问题

坏处:

  1. Camera+Render Texture会需要额外的纹理贴图开销,其次显示效果经过额外Shader处理不如直接照射效果好

这个以前实现过就不再实战了。

学习总结

  1. Mask的底层遮罩原理是使用了模板缓存机制
  2. RectMask2D的底层遮罩原理是利用C#层计算裁剪区域,然后传递给Shader开启UNITY_UI_CLIP_RECT进行裁剪区域显示判定
  3. 粒子特效默认是当做3D物体渲染,不参与Canvas Renderer流程,ParticleEffectForUGUI是通过将粒子特效自定义绘制到MaskableGraphic的UIParticleRenderer组件从而实现的粒子特效参与到Canvas Renderer以及支持Mask和RectMask2D遮罩的

Github

ParticleEffectForUGUI

Reference

走进 Stencil Buffer 系列 0 : 模板缓冲和模板测试是什么?

模板测试(Stencil Test)基本概念

ShaderLab: Stencil Buffer 的理解和应用

Unity 粒子特效在模型前面显示 unity ui粒子特效

UI上的特效的裁剪问题

解决Unity UI粒子缩放异常:ParticleEffectForUGUI的Scale参数完全指南

文章目录
  1. 1. 前言
  2. 2. Mask
    1. 2.1. Mask组件
      1. 2.1.1. Stencil Buffer
      2. 2.1.2. Mask+UI组件
      3. 2.1.3. Mask+粒子特效
        1. 2.1.3.1. Mask+自带ParticleAdditive Shader
        2. 2.1.3.2. Mask+修改ParticleAdditive Mask Shader
        3. 2.1.3.3. 仅使用自带UI Default Shader
        4. 2.1.3.4. ParticleEffectForUGUI
          1. 2.1.3.4.1. ParticleEffectForUGUI导入
          2. 2.1.3.4.2. 扩展粒子特效Shader支持Stencil和Clip
          3. 2.1.3.4.3. 制作粒子特效GameObject
          4. 2.1.3.4.4. UIParticle缩放
          5. 2.1.3.4.5. UIParticle遮罩
          6. 2.1.3.4.6. UIParticle层级
          7. 2.1.3.4.7. UIParticleAttractor
    2. 2.2. RectMask2D组件
      1. 2.2.1. 将粒子特效重定向到CanvasRenderer里去渲染
      2. 2.2.2. 手动传递RectMask2D裁剪区域到粒子Shader
  3. 3. 3D和2D混合显示及决方案
    1. 3.1. Extra 3D Model Camera
    2. 3.2. 3D Camera + Render Texture
  4. 4. 学习总结
  5. 5. Github
  6. 6. Reference