做一个类似于《INK》的2D颜料泼溅效果:
表面与颜料
利用模板测试,让颜料污渍能在物体表面上重叠显示且不超出物体轮廓。在物体表面的Shader中,总是通过模板测试并替换参考值,同时裁剪掉透明度为0的像素;颜料污渍的Shader中,如果与参考值相等则通过测试。
项目用了URP,这里直接将Sprite-Lit-Default.shader拷贝两份出来修改。
被染色表面Surface-Lit.shader
SubShader中加入Stencil配置
SubShader
{
Tags {"Queue" = "Transparent" "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" }
Stencil
{
Ref 2
Comp Always
Pass Replace
}
...
透明度裁剪在原来的Sprite-Lit-Default.shader中已经做好了所以不用再写。
颜料污渍Stain-Lit.shader
SubShader
{
Tags {"Queue" = "Transparent" "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" }
Stencil
{
Ref 2
Comp Equal
}
...
建一个场景测试效果,Tilemap使用Surface-Lit材质,污渍Sprite使用Stain材质,二者在同一个Sorting Layer,Tilemap的Order in Layer需要比污渍Sprite小。
Tilemap:
颜料污渍:
颜料污渍已经可以显示在Tilemap上并且不会超出其轮廓。
喷溅
添加一把玩具水枪,简单写一个发射颜料子弹的逻辑,子弹爆开后围绕当前位置随机生成多个颜料污渍预制体。颜料污渍预制体用一张1像素的白色图片,通过缩放来显示出不同大小的污渍。子弹中设置多种颜色随机应用到子弹与污渍的SpriteRenderer。
Stain Radius
为喷溅半径,Stain Scale
为最大污渍缩放。
编写StainGenerator类用来生成颜料污渍:
public class StainGenerator
{
/// <summary>
/// 在中心点生成向四周发散的污渍
/// </summary>
/// <param name="prefab">污渍预制体</param>
/// <param name="color">颜色</param>
/// <param name="position">中心点位置</param>
/// <param name="direction">冲击方向</param>
/// <param name="scale">污渍缩放</param>
/// <param name="radius">污渍分布半径</param>
public static void Generate(GameObject prefab, Color color, Vector3 position, Vector3 direction, float scale, float radius)
{
// 以position为中心分裂到若干个方向,每个分裂的角度随机
int splitNum = Random.Range(4, 9); // 分裂数量随机,数值暂时写死
Vector3[] splitDirs = new Vector3[splitNum];
float angleDelta = 360f / splitNum;
for (int i = 0; i < splitDirs.Length; i++)
{
var lastDir = i == 0? direction : splitDirs[i - 1];
var angle = RandomNum(angleDelta, .2f);
splitDirs[i] = Quaternion.AngleAxis(angle, Vector3.forward) * lastDir;
}
// 每个分裂方向生成若干个污渍
foreach (var dir in splitDirs)
{
int stainNum = Random.Range(3, 6);
float stainScale; // 污渍
float radiusDelta = radius / 6f; // 每个污渍间距
Vector3 stainPos = position; // 污渍位置
for (int i = 0; i < stainNum; i++)
{
stainScale = scale - (i * scale / stainNum); // 缩放随距离衰减
stainPos += dir * RandomNum(radiusDelta, .4f);
stainPos += (Vector3) Random.insideUnitCircle * RandomNum(radiusDelta * .2f, radiusDelta * .1f); // 位置随机
var go = Object.Instantiate(prefab);
go.transform.position = stainPos;
go.transform.right = dir;
go.transform.localScale = new Vector3(stainScale, stainScale, 1f);
go.GetComponent<SpriteRenderer>().color = color;
}
}
}
public static float RandomNum(float num, float randomness)
{
return num + Random.Range(-num * randomness, num * randomness);
}
}
子弹与地面发生碰撞时调用,传入污渍预制体、颜色、冲击位置、冲击方向、自定义的污渍缩放与喷溅半径:
private void OnCollisionEnter2D(Collision2D other)
{
...
StainGenerator.Generate(stainPrefab, color, transform.position, transform.right, stainScale, stainRadius);
}
比较简单的实现就完成了,但这种不断生成Sprite的方法将导致场景里分分钟就会多出上千个游戏对象。
优化
由于污渍的材质都一样,Unity对它们做了动态合批,这里是否还可以通过自定义mesh来优化,暂时没有什么头猪。
好在对游戏对象的数量优化还是比较简单的,打在同一个位置的颜料污渍将会发生重叠,被遮挡住的污渍是不再需要的。定义一个同一位置最大可叠加层数,在创建新的污渍时,先判断当前位置共叠加了几层,如果超过允许的最大层数,则将最底层的污渍对象回收,再从对象池中取出已回收的污渍对象重复利用。
这种做法的缺点是只判断重叠,而不是判断污渍是否被完全覆盖,显示效果上不太好,最大层数设置较低时会出现有些污渍还未被完全覆盖,却依然被回收了的情况。
检测重叠可以使用SpriteRenderer中Bounds的Intersects方法,但每次生成都要遍历当前所有污渍对象做判断,感觉过于繁琐。最终还是选择用了Physics2D.OverlapBoxAll,这样要先在污渍预制体中添加BoxCollider2D。
修改StainGenerator,令它继承MonoBehaviour。叠加层数利用SpriteRenderer的Order in Layer属性实现,假设三个污渍重叠,且它们的Order in Layer分别是2、3、4,如果此时已达到了最大叠加层数,同时有新的污渍即将覆盖它们,则回收Order为2的,其余的Order减1,新的污渍Order设为4,这样依然能保持2、3、4的重叠顺序。
public class StainGenerator : MonoBehaviour
{
public static StainGenerator Instance { get; private set;}
[Tooltip("污渍Prefab")]
public GameObject prefab;
[Tooltip("最小Order in Layer")]
public int minOrderInLayer;
[Tooltip("最大Order in Layer")]
public int maxOrderInLayer;
[Tooltip("污渍大小")]
public Vector2 stainSize;
// 污渍对象池
[SerializeField] List<GameObject> stainPool;
// 临时污渍对象列表,用来记录本次生成已处理过的对象,避免重复处理
List<GameObject> tempStains;
void Awake()
{
// 初始化单例和列表
...
}
...
}
修改Generate方法,去除多余的参数,仅改动污渍对象生成部分,子弹碰撞中相应修改对其的调用。
public void Generate(Color color, Vector3 position, Vector3 direction, float scale, float radius)
{
// 以position为中心分裂到若干个方向,每个分裂的角度随机
...
// 每个分裂方向生成若干个污渍
tempStains.Clear(); // 每次生成时清空临时列表
foreach (var dir in splitDirs)
{
...
for (int i = 0; i < stainNum; i++)
{
...
// var go = Object.Instantiate(prefab);
var go = GetStain(stainPos, stainScale, dir); // 替换为GetStain方法
...
}
}
}
编写GetStain方法。
/// <summary>
/// 获取当前污渍对象,若当前位置发生重叠则调整回收
/// </summary>
/// <param name="pos">位置</param>
/// <param name="scale">缩放</param>
/// <param name="dir">朝向</param>
/// <returns></returns>
GameObject GetStain(Vector3 pos, float scale, Vector3 dir)
{
int order = minOrderInLayer; // 当前污渍需要设置的sortingOrder
var angle = Vector2.SignedAngle(Vector2.right, dir);
var size = stainSize * scale; // 实际大小
var cols = Physics2D.OverlapBoxAll(pos, size, angle, LayerMask.GetMask("Stain"));
if (cols.Length != 0)
{
// 若检测到污渍重叠,获取当前最顶层污渍的sortingOrder
SpriteRenderer spriteRenderer;
foreach (var item in cols)
{
spriteRenderer = item.GetComponent<SpriteRenderer>();
if (spriteRenderer.sortingOrder > order)
{
order = spriteRenderer.sortingOrder;
// 如果即将超出最大叠加层数则直接快进到处理重叠的污渍
if (order + 1 > maxOrderInLayer)
break;
}
}
if (order + 1 > maxOrderInLayer)
{
// 回收最底层的污渍,其余层sortingOrder减1,并标记为已处理,避免被重复处理
foreach (var item in cols)
{
spriteRenderer = item.GetComponent<SpriteRenderer>();
if (spriteRenderer.sortingOrder == minOrderInLayer)
item.gameObject.SetActive(false);
else if (!tempStains.Contains(item.gameObject))
{
spriteRenderer.sortingOrder--;
tempStains.Add(item.gameObject);
}
}
}
order = Mathf.Clamp(order + 1, minOrderInLayer, maxOrderInLayer);
}
// 简易对象池
GameObject go = null;
foreach (var item in stainPool)
{
if (!item.activeInHierarchy)
{
go = item;
go.SetActive(true);
break;
}
}
if (go == null)
{
go = Instantiate(prefab, transform);
stainPool.Add(go);
}
go.GetComponent<SpriteRenderer>().sortingOrder = order;
return go;
}
脚本挂到场景中,设置相应值:
调整之后:
和调整前对比:
可以发现不再生成那么多对象了,帧数也相对稳定,但出现了上面提到的显示问题,将最大叠加层数调高基本可以解决。
用到的素材:Cavernas by Adam Saltsman、8 Guns + Projectiles by KingKelpo