前言
就普遍性而言,粒子系统常用在一闪而过的效果,来达到一种烘托氛围、增强玩家体验的目的,比如胜利撒个花,胜利过关放个烟花,等等。但这次就遇到了不普遍的:它出现在了列表中,并且会随着列表一起滑动,当超过Scroll Rect的Viewport范围时,它并不会像列表中其他UI元素一样,自觉消失于视野之中,而是以一种让人意想不到的方式,继续发着光、闪着亮。
1. 原因 首先需要知道的是,Scroll Rect中能够自动裁剪超过视窗的元素,是通过挂在 Viewport 上的 Mask 或 RectMask2D 组件实现的。原理很好理解,通过组件限定一个视窗范围,超过范围的便不绘制,其中 Mask 利用 GPU 的 Stencil Buffer,而 RectMask2D 利用坐标区域计算。
这些属于UGUI体系,使用其专有的CanvasRenderer,而粒子系统和这些UI元素不在同一个渲染管线,用的是ParticleSystemRenderer,它不认识Mask和RectMask2D,自然裁剪也影响不到它,所以在超出Mask的范围后,粒子依然会渲染。
2. 如何裁剪 从根本上来看,最终绘制都要经过 Shader,而裁剪效果的本质就是过滤掉区域外的像素(通过硬件直接丢弃像素,或在 frag 片元函数中将其绘制为全透明)。想要Shader实现这种效果,首先就是需要Shader自身支持,其次就需要把相关参数传给Shader。
从实现方式来看,大致可以分为2种。一种是通过GPU自带的Stencil Buffer模版测试功能,一种是额外添加ClipRect裁剪区域。
2.1 裁剪类型:Stencil Buffer Stencil Buffer的原理都好理解,类似于盖章。绘制之前先盖章,然后再绘制,但只绘制印章区域之内的,之外的不绘制,绘制完成后再擦除印章。这种方式会额外增加2次Draw Call,一次是盖章,一次是擦章。Mask使用的就是这种方式。
2.2 裁剪类型:Clip Rect ClipRect区域裁剪也很好理解,在Rect指定区域内的像素正常绘制,区域外的则使用透明色。这是纯数学计算,不涉及Draw Call。也是RectMask2D使用的方式。
3. Shader支持裁剪 上面提到,不管通过什么方式,最终到底层,都需要Shader自身支持在绘制时裁剪,而若想Shader支持这2种方式的裁剪,代码层面都较为简单。
3.1 Stencil Buffer 这个是GPU自带的功能,添加几行简单的代码就可实现:
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 // 1. 在 Properties 里加几个接收参数的槽位 Properties { _Stencil ("印章编号", Float) = 0 _StencilComp ("比对规则", Float) = 8 _StencilOp ("盖章还是保持", Float) = 0 _StencilWriteMask ("写入掩码", Float) = 255 _StencilReadMask ("读取掩码", Float) = 255 _ColorMask ("颜色掩码", Float) = 15 } // 2. 在 SubShader 里声明开启硬件模板测试(把上面的变量填进去) Stencil { Ref [_Stencil] // 印章编号是几? Comp [_StencilComp] // 怎么比对?(比如:相等才画) Pass [_StencilOp] // 比对通过后干嘛?(比如:盖章 / 保持不变) ReadMask [_StencilReadMask] WriteMask [_StencilWriteMask] } ColorMask [_ColorMask]
要想判断一个Shader是否支持Stencil Buffer,打开其源代码查看,如果有以上这些代码则说明Shader支持。
3.2 Clip Rect 由于这是纯数学计算,所以需要额外在frag中添加判断代码,也很简单,获取rect的范围,在区域内的正常绘制,在区域外的绘制透明像素,对于这个判断过程,已经在Unity UI工具包中提供了,可以直接调用。
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 #include "UnityUI.cginc" // 1. 引入 Unity UI 工具包(里面自带裁剪公式) float4 _ClipRect; // 2. 声明接收参数:一个包含 (左X, 下Y, 右X, 上Y) 的四维变量 struct v2f { float4 vertex : SV_POSITION; float4 worldPos : TEXCOORD1; // 准备一个槽位,把坐标从 vert 传给 frag }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.worldPos = v.vertex; // 3. 在 vert 里把顶点坐标存进 v2f(交给 GPU 去插值成每个像素的坐标) return o; } fixed4 frag (v2f i) : SV_Target { fixed4 color = ...; // 正常算出像素颜色 // 4. 在 frag 里调用官方一行公式: // 如果像素在 _ClipRect 框内,返回 1(透明度不变) // 如果像素在 _ClipRect 框外,返回 0(透明度乘 0 变成全透明!) color.a *= UnityGet2DClipping(i.worldPos.xy, _ClipRect); return color; }
4. 裁剪粒子 4.1 UIParticle 现已有mob-sakai实现成熟的方案:UIParticle,它的原理是禁用了粒子系统原本的 ParticleSystemRenderer,改用 CanvasRenderer 渲染粒子,这样一来就可以使用其支持的Mask和RectMask2D组件,但前提依旧是需要粒子系统使用的Shader支持裁剪。
项目地址:UIParticle
4.2 手动传参给Shader 如果是简单的Rect裁剪,也可以不额外添加UIParticle,直接手动通过Material给Shader传入区域盒子参数,修改原不支持裁剪的Shader使其支持ClipRect,这种较为简单,开销也较低。
这里是参考RectMask2D的裁剪方式,给粒子的渲染也加一个区域限制,区域内的渲染,超出区域范围的放弃渲染,以达到一个裁剪的效果。
看过Shader的都知道,Pass里主要就是2个函数,一个vert函数用来计算顶点坐标,还有一个frag函数用来计算像素的颜色,思路就是,给Shader增加一个区域参数,在得到像素坐标后,判断是否在目标区域内,如果在其中,则照常绘制渲染,若不在,则放弃绘制,将颜色设置为全透明即可。
这里的目标区域,用2个坐标表示,即滚动区域ViewPort的左下角和右上角的坐标,当然也可以用左上角和右下角的坐标,效果一样,最终都会得到4个数:
1 2 3 4 _MinX, _MinY, _MaxX, _MaxY,
这4个是Shader的属性,需要在程序运行后,把具体的值传递进去。
区域的坐标可通过Mask或者RectMask2D获取,有的Scroll View用的是Mask,也有一些用的是RectMask2D,具体看情况决定。
我们这里特效大致有这么几类:发光、烟花、彩带,拿一个常用的Shader举例:Additive。在Inspector中很容易就可以找到Shader的源码,复制一份出来,记得要改一下顶部Shader的路径,然后就可以着手修改了。
这一步是为了可以在代码中传递限定区域的坐标值
1 2 3 4 5 6 7 Properties { _MinX ("Min X", Float) = 0 _MaxX ("Max X", Float) = 0 _MinY ("Min Y", Float) = 0 _MaxY ("Max Y", Float) = 0 }
增加同名变量,以便于可以在frag函数中使用
1 2 3 4 5 6 7 Pass { float _MinX; float _MaxX; float _MinY; float _MaxY; }
为了便于计算,坐标空间需要统一,最省事的就是都是用世界坐标,代码中传递过来的就是转换之后的坐标,这样的即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 v2f vert(appdata_t v) { v2f o; // 省略了原有代码 o.pos = mul(unity_ObjectToWorld, v.vertex); return o; } fixed4 frag(v2f i) : SV_Target { // 省略了原有代码 float clipX1 = step(_MinX, i.pos.x); float clipX2 = step(i.pos.x, _MaxX); float clipY1 = step(_MinY, i.pos.y); float clipY2 = step(i.pos.y, _MaxY); float rectClip = clipX1 * clipX2 * clipY1 * clipY2; col.a *= rectClip; return col; }
其中,float c = step(a, b)是Shader内置的API函数,当b大于a时,返回0,剩下返回1,对像素坐标的判断,只有都在区域内时,最后的rectClip才为1,否则为0,最后返回的像素颜色也就成了全透明,视觉上就是被裁剪了。Shader中少用if,多用自带的api,由于GPU的计算方式不同于CPU,if这种是个高消耗的计算操作。
有些低端GPU上,使用step函数可能会有精度问题,这个时候也可以直接用if
1 2 3 4 5 6 7 float inRect = 1.0; if (i.pos.x < _MinX) inRect = 0.0; if (i.pos.x > _MaxX) inRect = 0.0; if (i.pos.y < _MinY) inRect = 0.0; if (i.pos.y > _MaxY) inRect = 0.0; col.a *= inRect;
3.实现MonoBehavior 脚本中做的事也很简单,先找到Mask或者RectMask2D获取限定区域的坐标,然后再找到Material,把坐标传递过去即可。由于项目有别,结构也是多种多样,获取这两个对象的方式自然不可同一而论,这里说一种最直接的方式:直接赋值,没有更直接的了。
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 using UnityEngine;using UnityEngine.UI;public class ClipParticleSystem : MonoBehaviour { public Mask mask; public Material[] materials; private void LateUpdate ( ) { Clip(); } private void Clip ( ) { var corner = new Vector3[4 ]; var rect = (RectTransform) mask.transform; rect.GetWorldCorners(corner); foreach (var mat in materials) { mat.SetFloat("_MinX" , corner[0 ].x); mat.SetFloat("_MinY" , corner[0 ].y); mat.SetFloat("_MaxX" , corner[2 ].x); mat.SetFloat("_MaxY" , corner[2 ].y); } } }
其中,Mask是Scroll Rect视窗的Mask,Material则是需要裁剪的粒子效果中所有的材质。当然,这样写很粗躁,主要是为了说明方式主干,其中还有不少可以优化的地方,比如:
缓存Mask的RectTransform,不必每一帧都获取
重复使用Vector3数组,不必每次都要new一个
获取材质属性的id,通过id传递值
判断坐标变化,若无变化则跳过这一帧
至于,获取Mask和Material的方式,就要因地制宜了,也可以通过GetComponent在代码中获取,因为节点结构的不同,这个就视情况而定,各显神通就好。
这里使用的是直接修改Shader参数的形式,经过验证,这种在Unity Editor上是可以奏效的,但是在一些移动端上,可能会出现裁剪无效的现象,具体原因不明。
上面只是为了简单说明原理。实际工程中不能直接通过 Material 传参,而应改用 MaterialPropertyBlock:因为直接修改 sharedMaterial 会误伤其他使用同材质的对象;而访问 material 会 Clone 一份新材质且不会自动回收,从而导致内存泄漏。MaterialPropertyBlock 则没有这些问题,使用方式也很类似:
1 2 3 4 5 6 if (_mpb == null ) _mpb = new MaterialPropertyBlock();_mpb.SetFloat(MinXId, minX); _mpb.SetFloat(MinYId, minY); _mpb.SetFloat(MaxXId, maxX); _mpb.SetFloat(MaxYId, maxY);
下面是大致实现:
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 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 using UnityEngine;using UnityEngine.UI;public class ClipParticleSystem : MonoBehaviour { private Mask _mask; private Renderer[] _renderers; private MaterialPropertyBlock _mpb; private RectTransform _maskRect; private bool _isMask; private readonly Vector3[] _corners = new Vector3[4 ]; private static readonly int MinXId = Shader.PropertyToID("_MinX" ); private static readonly int MinYId = Shader.PropertyToID("_MinY" ); private static readonly int MaxXId = Shader.PropertyToID("_MaxX" ); private static readonly int MaxYId = Shader.PropertyToID("_MaxY" ); private Vector4 _lastCorners; public void Start ( ) { _mpb = new MaterialPropertyBlock(); _mask = GetComponentInParent<Mask>(); if (_mask) _maskRect = _mask.GetComponent<RectTransform>(); _renderers = GetComponentsInChildren<Renderer>(); UpdateMaterialClip(-99999 , -99999 , 99999 , 99999 ); _lastCorners = new Vector4(-99999 , -99999 , 99999 , 99999 ); SetClip(); } private void OnDestroy ( ) { _mpb?.Clear(); _mpb = null ; } private void LateUpdate ( ) { SetClip(); } private void SetClip ( ) { if (_mask == null ) { _mask = GetComponentInParent<Mask>(); if (_mask) { _maskRect = _mask.GetComponent<RectTransform>(); } } _isMask = false ; if (_mask && _mask.gameObject.activeInHierarchy) { _maskRect.GetWorldCorners(_corners); _isMask = true ; } if (!_isMask) { _corners[0 ].x = -99999 ; _corners[0 ].y = -99999 ; _corners[2 ].x = 99999 ; _corners[2 ].y = 99999 ; } if (!Mathf.Approximately(_lastCorners.x, _corners[0 ].x) || !Mathf.Approximately(_lastCorners.y, _corners[0 ].y) || !Mathf.Approximately(_lastCorners.z, _corners[2 ].x) || !Mathf.Approximately(_lastCorners.w, _corners[2 ].y)) { UpdateMaterialClip(_corners[0 ].x, _corners[0 ].y, _corners[2 ].x, _corners[2 ].y); } } private void UpdateMaterialClip (float minX, float minY, float maxX, float maxY ) { _lastCorners.x = minX; _lastCorners.y = minY; _lastCorners.z = maxX; _lastCorners.w = maxY; if (_mpb == null ) _mpb = new MaterialPropertyBlock(); _mpb.SetFloat(MinXId, minX); _mpb.SetFloat(MinYId, minY); _mpb.SetFloat(MaxXId, maxX); _mpb.SetFloat(MaxYId, maxY); if (_renderers != null ) { foreach (var t in _renderers) { if (t != null ) { t.SetPropertyBlock(_mpb); } } } } }