oynix

于无声处听惊雷,于无色处见繁花

裁剪粒子系统

前言

就普遍性而言,粒子系统常用在一闪而过的效果,来达到一种烘托氛围、增强玩家体验的目的,比如胜利撒个花,胜利过关放个烟花,等等。但这次就遇到了不普遍的:它出现在了列表中,并且会随着列表一起滑动,当超过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的裁剪方式,给粒子的渲染也加一个区域限制,区域内的渲染,超出区域范围的放弃渲染,以达到一个裁剪的效果。

  • 1.获取区域坐标

看过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,具体看情况决定。

  • 2.在Shader中增加参数

我们这里特效大致有这么几类:发光、烟花、彩带,拿一个常用的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();

// Try to initialize mask
_mask = GetComponentInParent<Mask>();
if (_mask) _maskRect = _mask.GetComponent<RectTransform>();

_renderers = GetComponentsInChildren<Renderer>();

// Initial clip set to infinite to prevent disappearance if mask not found
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()
{
// Robustness: Try to find mask if missing
if (_mask == null)
{
_mask = GetComponentInParent<Mask>();
if (_mask)
{
_maskRect = _mask.GetComponent<RectTransform>();
}
}

// Get clipping area
_isMask = false;

// Check for mask existence (ignoring enabled state for logical clipping purposes)
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();

// Only clear the specific properties we are managing to avoid wiping other MPB data if any
_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);
}
}
}
}
}
------------- (完) -------------
  • 本文作者: oynix
  • 本文链接: https://oynix.com/2025/11/5f5af27a11fb/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

欢迎关注我的其它发布渠道