oynix

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

裁剪粒子系统

前言

就普遍性而言,粒子系统常用在一闪而过的效果,来达到一种烘托氛围、增强玩家体验的目的,比如胜利撒个花,胜利过关放个烟花,等等。但这次就遇到了不普遍的:它出现在了列表中,并且会随着列表一起滑动,当超过Scroll Rect的Viewport范围时,它并不会像列表中其他UI元素一样,自觉消失于视野之中,而是以一种让人意想不到的方式,继续发着光、闪着亮。

1. 原因

首先需要知道的是,Scroll Rect中能够自动裁剪超过视窗的元素,是通过挂在Viewport上的Mask组件实现的,与之类似的还有RectMask,等等。原理很好理解,通过Mask组件来限定一个范围,超过这个范围的便不绘制,利用GPU的Stencil Buffer对Canvas Renderer绘制的UI进行裁剪。

而粒子系统和这些UI元素不在同一个渲染管线,用的是ParticleSystemRenderer,Mask的裁剪影响不到它,所以在超出Mask的范围后,粒子依然会渲染。

2. 如何裁剪

这里只是说一种方式,当然还有其他方式,有想法可以多研究研究。

这里是参考Mask的裁剪方式,给粒子的渲染也加一个区域限制,区域内的渲染,超出区域范围的放弃渲染,以达到一个裁剪的效果。

看过Shader的都知道,Pass里主要就是2个函数,一个vert函数用来计算顶点坐标,还有一个frag函数用来计算顶点的颜色,思路就是,给Shader增加一个区域参数,在得到顶点坐标后,判断是否在目标区域内,如果在其中,则照常绘制渲染,若不在,则放弃绘制,将颜色设置为全透明即可。

这里的目标区域,用2个坐标表示,即Mask的左下角和右上角的坐标,当然也可以用左上角和右下角的坐标,效果一样,最终都会得到4个数:

1
2
3
4
_MinX,
_MinY,
_MaxX,
_MaxY,

这4个是Shader的属性,需要在程序运行后,把具体的值传递进去。

3. 实现Shader

我们这里特效大致有这么几类:发光、烟花、彩带,拿一个常用的Shader举例:Additive。在Inspector中很容易就可以找到Shader的源码,复制一份出来,记得要改一下顶部Shader的路径,然后就可以着手修改了。

3.1 增加属性

这一步是为了可以在代码中传递限定区域的坐标值

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
}

3.2 增加变量

增加同名变量,以便于可以在frag函数中使用

1
2
3
4
5
6
7
Pass
{
float _MinX;
float _MaxX;
float _MinY;
float _MaxY;
}

3.3 裁剪

为了便于计算,坐标空间需要统一,最省事的就是都是用世界坐标,代码中传递过来的就是转换之后的坐标,这样的即可

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 farg(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,对4个值分布判断,只有都在区域内时,最后的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;

4. 实现MonoBehavior

脚本中做的事也很简单,先找到Mask获取限定区域的坐标,然后再找到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是Scorll Rect视窗的Mask,Material则是需要裁剪的粒子效果中所有的材质。当然,这样写很粗躁,主要是为了说明方式主干,其中还有不少可以优化的地方,比如:

  • 缓存Mask的RectTransform,不必每一帧都获取
  • 重复使用Vector3数组,不必每次都要new一个
  • 获取材质属性的id,通过id传递值
  • 判断坐标变化,若无变化则跳过这一帧

至于,获取Mask和Material的方式,就要因地制宜了,也可以通过GetComponent在代码中获取,因为节点结构的不同,这个就视情况而定,各显神通就好。

这里使用的是直接修改Shader参数的形式,经过验证,这种在Unity Editor上是可以奏效的,但是在一些移动端上,可能会出现裁剪无效的现象,具体原因不明,但可以改用MaterialPropertyBlock,可以避免生成材质实例以及增加DrawCall,形式很类似:

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
117
using UnityEngine;
using UnityEngine.UI;

public class ClipParticleSystem : MonoBehaviour
{
private Mask _mask;
private Renderer[] _renderers;
private MaterialPropertyBlock _mpb;

private RectTransform _maskRect;
private RectTransform _rectMask2DRect;

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 许可协议。转载请注明出处!

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