[Unity]2D颜料泼溅效果
-
做一个类似于《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 StainGeneratorOld { /// <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