木骰

关于RectMask2D裁剪粒子特效的一种便捷方案

用RectMask2D裁剪粒子特效的做法,一般都是修改一些特效用到的Shader,把RectMask2D的Rect坐标值传参到Shader,然后比对像素坐标和Rect范围,将不再Rect范围内的像素alpha值置0,达到裁剪的目的。

前段时间重新理了一遍RectMask2D的裁剪流程,发现可以把对粒子特效的裁剪过程,封装到RectMask2D本身的裁剪流程中去,使这个过程和效果,与对Image等常规UI元素的裁剪基本一致,不再需要额外关注对特效的裁剪。

先看看RectMask2D的裁剪过程

RectMask2D继承自IClipper接口,该接口下有一个 PerformClipping() 方法,该接口会由Canvas.willRenderCanvases触发每帧调用,可以去看一下RectMask2D对该接口的实现,

Alt text
RectMask2D中维护了两个Set,一个IClippable类型的,一个MaskableGraphic类型的。其中MaskableGraphic类型是所有常规UI元素,Image,Text,RawImage等的基类,RectMask2D对它们的裁剪就依赖这个基类。
而IClippable又是MaskableGraphic继承的一个接口,RectMask2D组件会把它的所有子节点中的IClippable类型全部分类添加到这两个集合中,然后在这个PerformClipping方法中处理裁剪。

Alt text
可以看一下PerformClipping方法中裁剪的主要处理,对IClippable接口对象调用了SetClipRect方法,对MaskableGraphic类对象额外还调用了一个Cull方法。SetClipRect 与 Cull 这两个就是IClippable接口中需要实现的两个方法了。

注意UGUI2019之前的版本只维护了一个m_ClipTargets集合,并且对IClippable和MaskableGraphic对象都每帧调用了Cull方法,没有做详细的区分。

看一下MaskableGraphic对这两个接口的实现,
发现SetClipRect接口主要是用来传递ClipRect参数给Shader,而Cull方法是做的一个UI元素的裁剪,注意此处的裁剪是指元素完全离开RectMask2D范围之后的一个剔除操作,而非前面所说的裁剪。

可以看到,RectMask2D通过IClippable接口来管理裁剪对象,由于我们的粒子特效并不是UI元素,不能继承MaskableGraphic,那么可以继承一下IClippable接口,同样可以交由RectMask2D来管理裁剪流程,实现与MaskableGraphic类似的效果。

针对特效的裁剪组件 ParticleMaskClippable

仿照MaskableGraphic的实现,写一个针对特效的裁剪组件 ParticleMaskClippable
仿照实现SetClipRect方法。

public virtual void SetClipRect(Rect clipRect, bool validRect)
{
if(m_ParentMask == null || !m_ParentMask.IsActive() || Canvas == null)
{
return;
}
if(!validRect || clipRect.width == 0 || clipRect.height == 0)
{
//Cull(clipRect, validRect);
UpdateCull(true);
}
else
{
if(materials != null)
{
Vector3 v1 = Canvas.transform.TransformPoint(clipRect.xMin, clipRect.yMin, 0);
Vector3 v2 = Canvas.transform.TransformPoint(clipRect.xMax, clipRect.yMax, 0);
Vector4 vec4 = new Vector4(v1.x, v1.y, v2.x, v2.y);
foreach(var mat in materials)
{
mat.SetVector("_ClipRect", vec4); // 设置裁剪区域
mat.SetFloat("_UseClipRect", 1.0f);
}
}
}
}

MaskableGraphic是UI元素,通过CanvasRender组件开关裁剪开关并传递clipRect,但总归是传值到shader,对于粒子特效,我们自己获取一下Material传值就可以了。
需要注意一下的是
这里RectMask2D传递过来的clipRect是相对于节点归属的第一个Canvas的坐标,而Shader里计算用的是相对世界坐标的值,所以这里需要获取Canvas还原到世界坐标。
还有一个就是这个validRect本来是RectMask裁剪边界无效的一个标记,由于存在多个RectMask2D共同作用(RectMask2D的父级还有RectMask2D),这里的clipRect取的是多个RectMask2D共同作用的交集,也就是最小可显示区域,有时候这个区域可能非法,或者为0,也就表示没有无可显示区域,所有元素将被完全裁剪,那么这里可以调一个UpdateCull方法,标记元素被完全裁剪的处理。

private void UpdateCull(bool cull)
{
if(cull)
{
//完全不可见 可以考虑SetActive(false) 或移出相机可视layer 但是可能会影响功能逻辑
if (materials != null)
{
foreach (var mat in materials)
{
mat.SetVector("_ClipRect", Vector4.zero); // 设置裁剪区域
mat.SetFloat("_UseClipRect", 1.0f);
}
}
}
else
{
if(materials != null)
{
foreach (var mat in materials)
{
//关掉裁剪
mat.SetFloat("_UseClipRect", 0.0f);
}
}
}
}

这个UpdateCull方法也是参照的MaskableGraphic中的方法,原本此处的功能也就是对被完全裁剪的元素设置屏蔽渲染,源码中是通过设置CanvasRender.cull标记来达到的,这个标记会在Graphic Rebuild的时候过滤网格和材质的重建,此处对于特效我们直接把ClipRect传0达到完全裁剪的目的。
此处对于完全裁剪的处理可以考虑设置Active状态 或者移出相机视野等,直接过滤掉特效的渲染,但是需要考虑好避免跟业务逻辑有冲突。
另外对于IClippable接口中的Cull方法,只留了一个空方法,原本MaskableGraphic对UpdateCull就是通过这个接口触发的,但是UGUI 2019之后的版本对于只继承IClippable的对象不再调用Cull,所以写了也没什么用,而对于2019之前的版本,会随着WillRenderCanvas每帧调用,而我们也不需要这么频繁地触发裁剪,只需要再每次触发SetClipRect的时候设置一下就可以了。
另外还有一个UpdateClipParent方法,这个方法基本跟MaskableGraphic中一样,用来将自身注册到RectMask2D中的m_ClipTargets集合中。需要在OnEnable,OnDisable,OnDestroy以及RectMask2D触发的RecalculateClipping回调中调用一下。

Alt text
RectMask2D的一些接口在编辑器下也会触发,所以为了使组件在编辑器下能够正常使用,我加了ExecuteInEditMode标签。
Require RectTransform组件是因为IClippable接口中有对RectTransform的get访问器,一些地方可能会访问到,毕竟这些操作本身是针对UI元素的。
另外我还开了两个bool字段。

Alt text
IncludeChild勾选之后将会获取所有子节点的Material,同步设置_ClipRect和_UseClipRect,也就是说勾上这个选项之后,只需要在特效根节点挂上该组件就能实现包括所有子节点特效的裁剪。对于一些情况不需要统一处理所有子节点,也可以选择为每个需要裁剪的节点对象单独挂载该组件。
UseNewMaterial勾选之后组件将不对SharedMaterial进行修改,因为特效的材质球可能会用于多个材质球,或者这个特效可能会在多个地方用到,为了避免对材质球本身的修改,可以选择新建一个Material实例进行修改,直接访问Renderer下的Material字段将会自动新建一个Material实例并应用。

Alt text

以上就基本能实现与一般UI元素类似的裁剪效果了。可以自动响应参数,层级,enable状态等导致的变化。

Alt text

噢 记得要改下特效用的Shader,加上 _ClipRect和_UseClipRect 字段。大概像这样:
float c = UnityGet2DClipping(IN.worldPosition.xy, _ClipRect); // 2D Mask 剪裁
col.a *= lerp(col.a, c * col.a, _UseClipRect);

列一下完整代码

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
[ExecuteInEditMode]
[RequireComponent(typeof(RectTransform))]
public class ParticleMaskClippable : UIBehaviour, IClippable
{
public bool m_IncludeChild = false;
[Tooltip("不改变ShaderdMaterial要运行时才会生效")]
public bool m_UseNewMaterial = false;
private RectMask2D m_ParentMask;
private Material[] m_Materials;
public Material[] materials
{
get
{
if(m_Materials == null)
{
if(m_IncludeChild)
{
Renderer[] renders = GetComponentsInChildren<Renderer>(true);
if(renders != null)
{
Dictionary<Material, bool> map = new Dictionary<Material, bool>();
foreach(var render in renders)
{
GetMatsByRenderer(render, map);
}
if(map.Count > 0)
{
m_Materials = map.Keys.KToArray();
}
map = null;
}
}
else
{
Renderer render = GetComponent<Renderer>();
if(render != null)
{
Dictionary<Material, bool> map = new Dictionary<Material, bool>();
GetMatsByRenderer(render, map);
if (map.Count > 0)
{
m_Materials = map.Keys.KToArray();
}
map = null;
}
}
}
return m_Materials;
}
}
void GetMatsByRenderer(Renderer render, Dictionary<Material, bool> map)
{
Material[] mats;
if (m_UseNewMaterial)//不使用SharedMaterial要运行时才会生效
{
#if UNITY_EDITOR
mats = Application.isPlaying ? render.materials : null;
#else
mats = render.materials;
#endif
}
else
{
mats = render.sharedMaterials;
}
if(mats!=null)
{
foreach(var mat in mats)
{
if(!map.ContainsKey(mat))
{
map.Add(mat, true);
}
}
}
}
private RectTransform m_RectTransform;
public RectTransform rectTransform {
get
{
if(m_RectTransform == null)
{
m_RectTransform = GetComponent<RectTransform>();
if(m_RectTransform == null)
{
m_RectTransform = gameObject.AddComponent<RectTransform>();
}
}
return m_RectTransform;
}
}
private Canvas m_Canvas;
private Canvas Canvas
{
get
{
if (m_Canvas == null)
{
var list = gameObject.GetComponentsInParent<Canvas>(false);
if (list.Length > 0)
m_Canvas = list[list.Length - 1];
else
m_Canvas = null;
list = null;
}
return m_Canvas;
}
}
protected override void OnEnable()
{
base.OnEnable();
UpdateClipParent();
}
protected override void OnDisable()
{
base.OnDisable();
UpdateClipParent();
}
protected override void OnDestroy()
{
base.OnDestroy();
UpdateClipParent(false);
}
#if UNITY_EDITOR
protected override void OnValidate()
{
base.OnValidate();
m_Materials = null;
ClearSharedMatClipSet();
UpdateClipParent();
}
[ContextMenu("ClearClipSet")]
public void ClearSharedMatClipSet()
{
Renderer[] renders = GetComponentsInChildren<Renderer>(true);
foreach(var render in renders)
{
foreach(var mat in render.sharedMaterials)
{
mat.SetVector("_ClipRect", Vector4.zero);
mat.SetFloat("_UseClipRect", 0.0f);
}
}
}
#endif
protected override void OnTransformParentChanged()
{
UpdateClipParent();
}
protected override void OnCanvasHierarchyChanged()
{
m_Canvas = null;
UpdateClipParent();
}
public virtual void RecalculateClipping()
{
UpdateClipParent();
}
public virtual void Cull(Rect clipRect, bool validRect)
{
//2019之后的版本对只继承IClippable接口的对象不会调用此方法 2019前的版本会随willRenderCanvases每帧调用
}
private void UpdateCull(bool cull)
{
if(cull)
{
//完全不可见 可以考虑SetActive(false) 或移出相机可视layer 但是可能会影响功能逻辑
if (materials != null)
{
foreach (var mat in materials)
{
mat.SetVector("_ClipRect", Vector4.zero); // 设置裁剪区域
mat.SetFloat("_UseClipRect", 1.0f);
}
}
}
else
{
if(materials != null)
{
foreach (var mat in materials)
{
//关掉裁剪
mat.SetFloat("_UseClipRect", 0.0f);
}
}
}
}
public virtual void SetClipRect(Rect clipRect, bool validRect)
{
if(m_ParentMask == null || !m_ParentMask.IsActive() || Canvas == null)
{
return;
}
if(!validRect || clipRect.width == 0 || clipRect.height == 0)
{
//Cull(clipRect, validRect);
UpdateCull(true);
}
else
{
if(materials != null)
{
Vector3 v1 = Canvas.transform.TransformPoint(clipRect.xMin, clipRect.yMin, 0);
Vector3 v2 = Canvas.transform.TransformPoint(clipRect.xMax, clipRect.yMax, 0);
Vector4 vec4 = new Vector4(v1.x, v1.y, v2.x, v2.y);
foreach(var mat in materials)
{
mat.SetVector("_ClipRect", vec4); // 设置裁剪区域
mat.SetFloat("_UseClipRect", 1.0f);
}
}
}
}
private void UpdateClipParent(bool alive = true)
{
var newParent = (alive && IsActive()) ? MaskUtilities.GetRectMaskForClippable(this) : null;
// if the new parent is different OR is now inactive
if (m_ParentMask != null && (newParent != m_ParentMask || !newParent.IsActive()))
{
var mask = m_ParentMask;
m_ParentMask = null;
mask.RemoveClippable(this);
UpdateCull(false);
}
// don't re-add it if the newparent is inactive
if (newParent != null && newParent.IsActive())
newParent.AddClippable(this);
m_ParentMask = newParent;
}
}

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*