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这种是个高消耗的计算操作。

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在代码中获取,因为节点结构的不同,这个就视情况而定,各显神通就好。

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

public class ClipParticleSystem : MonoBehaviour
{
public Mask mask;
public Material[] materials;

private RectTransform _rect;
private Vector3[] _cor;
private Vector4 _lastCor;

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 void Start()
{
_rect = mask.transform.GetComponent<RectTransform>();
_cor = new Vector3[4];
}

private void LateUpdate()
{
Clip();
}

private void Clip()
{
var rect = (RectTransform)mask.transform;
rect.GetWorldCorners(_cor);

if (!Mathf.Approximately(_lastCor.x, _cor[0].x) ||
!Mathf.Approximately(_lastCor.y, _cor[0].y) ||
!Mathf.Approximately(_lastCor.z, _cor[2].x) ||
!Mathf.Approximately(_lastCor.w, _cor[2].y))
{
_lastCor.x = _cor[0].x;
_lastCor.y = _cor[0].y;
_lastCor.z = _cor[2].x;
_lastCor.w = _cor[2].y;

foreach (var mat in materials)
{
mat.SetFloat(MinXId, _cor[0].x);
mat.SetFloat(MinYId, _cor[0].y);

mat.SetFloat(MaxXId, _cor[2].x);
mat.SetFloat(MaxYId, _cor[2].y);
}
}
}
}
------------- (完) -------------
  • 本文作者: oynix
  • 本文链接: https://oynix.com/2025/11/5f5af27a11fb/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

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