=> GuardHei

Illusory Walls Ahead

F+ or Deferred? Which one should I choose?


Here we go again!

在Unity中实现场景中动态血迹效果

概述

上一篇博客聊到了关于如何制作溅出的血迹附着在场景上的效果。大概效果参见下图:

screenshot1

上面这张图上可以很明显看到红色和紫色的血迹在地上遗留,这次主要就是要实现这个效果。

问题

那实现这个效果主要有哪些问题需要解决呢?

  1. 如何以较低的成本获得不同的血的形状?
  2. 如何知道血迹应该放置在哪里?
  3. 如何使血迹能贴合地附着在物体的表面
  4. 如何有效率地绘制大片大片的血迹

实现

一个可能的做法(但是很明显不是本篇重点)

如果是以前做过fps游戏的读者可能看到这个问题第一反应是直接把血迹贴图画在物体表面的材质纹理上,然后过一段时间再把原来的纹理画回去。这种做法做起来比较简单直接,但是有很多问题没法解决,下面就来大概列一下:

  1. 效率问题。首先每绘制一次血迹就要遍历一边血迹贴图的所有像素点,再绘制到物体表面的贴图上。对于fps游戏里的弹痕来说,因为比较小,分辨率很有可能也就64x64,这样遍历一遍需要4096次,不算高效,但至少可以接受。如果换成血迹,一个低清的血迹贴图也要在256x256左右,也就意味着遍历一遍需要65536次,同时还有cpu绘制贴图等等耗时操作,如果一个角色身上滋滋冒血,整个游戏就得靠眨眼补帧了。此外,由于贴图被破坏,一些原本可以进行的优化就被打断了。
  2. 显示问题。事实上,这种方法也不能保证显示正确。在很多情形下物体的贴图并不是严格uv一对一的,拉伸变形,重复铺开是非常常见的。以Unity自带的地形系统为例,材质都是tiled类型,如果直接画在贴图上,地面上会很有规律地出现多处血迹。而在一些拉伸物体上很有可能导致血迹突然变大变糊。

高效实现

那就开始说说我是怎么实现的吧。首先,针对之前提出的问题一个个写出解决思路:

  1. 血溅出来肯定是各种各样的,但是要美工一种种画出来成本太高,所以我们只要美工出一份血迹贴图即可。我们使用一张面片quad加上贴图来表示血迹。通过对面片在一定范围内的放大缩小旋转,可以近似的创造出不同的形状的血迹,这是一个很有用的视觉欺诈技巧。为了更好的效果,我们用着色器shader里的Tint Color选项来对血迹的颜色也作出一定的变化,比如从浅红到黑红色,这就意味着我们需要的血迹贴图是一个纯白的,这样才方便覆盖上颜色。
  2. 角色受到攻击溅出血一般都是用一个粒子效果做出来的,所以使用粒子系统的物理碰撞检测获得溅出来的血与场景碰撞的世界坐标,就知道血迹该画哪了。
  3. 同样,通过粒子系统的物理碰撞检测获得碰撞体表面的法线,将血迹面片的朝向与碰撞表面的朝向弄成一致的就行了,为了避免z-fighting导致的交替闪烁,需要将面片适当地延法线方向偏移出去一点。
  4. 这个问题很重要啊,由于我们需要不同大小的血迹,所以动态批处理dynamic batching是指望不上了。权衡后我决定用gpu instancing来进行优化。但Unitygpu instancing是一个dt的话题,里面有很多坑,一不小心就触犯了什么禁忌,导致优化失败。所以为了方便优化,我们直接使用一个粒子系统来管理所有的血迹,这样粒子系统会帮我们自动进行gpu instancing

步骤

创建血迹管理对象

由于我们使用了粒子系统来管理所有的血迹,所以我们先在场景里创造出一个管理的游戏对象。在场景里新建一个空白的GameObject,挂上一个ParticleSystem组件,然后进行一些必要的设置:

  1. Simulation Space改成World,这样保证后面物理碰撞的相关数据都是基于世界空间的而不是父空间的。
  2. 取消LoopingPlay On Awake选项,我们只是借Particle System做优化,不需要真的做个粒子上去。
  3. 取消EmissionShape两个模块Module选项,因为我们不需要粒子喷射效果。
  4. 展开Render模块,将Render Mode改成Mesh,因为我们需要渲染一个个血迹面片。
  5. 在下面的网格Mesh选项中使用面皮Quad。同时在后面的材质Material选项中使用血迹材质。但是我们目前还没有血迹材质,所以我们先来创建一个血迹材质。
  6. 创建一个新的材质,选择Particles/Alpha Blended着色器shader,将血迹贴图拖到粒子材质Particle Texture选项上。再将这个创建好的材质拖到上面说的粒子系统的材质选项上。
  7. 将Render模块中的Render Alignment选项设为World,同时勾上下面的Enable Mesh GPU Instancing选项,其他就不用变了。
  8. 最后,创建一个血迹管理脚本挂在上面,脚本内容如下:
using System;
using UnityEngine;
using Random = UnityEngine.Random;

public class ParticleDecalManager : MonoBehaviour {

	public const int CAPACITY = 2000;
	
	private static Transform decalRoot;

	private static int index;
	private static ParticleSystem particleSystem;
	private static readonly ParticleDecalData[] data = new ParticleDecalData[CAPACITY];
	private static readonly ParticleSystem.Particle[] particles = new ParticleSystem.Particle[CAPACITY];
	
	private void Awake() {
		decalRoot = transform;
		particleSystem = GetComponent<ParticleSystem>();
		for (int i = 0; i < CAPACITY; i++) data[i] = new ParticleDecalData();
	}

	public static void OnParticleHit(ParticleCollisionEvent @event, float size, Color color) {
		SetParticleData(@event, size, color);
	}

	private static void SetParticleData(ParticleCollisionEvent @event, float size, Color color) {
		if (index >= CAPACITY) index = 0;
		ParticleDecalData data = ParticleDecalManager.data[index];
		Vector3 euler = Quaternion.LookRotation(@event.normal).eulerAngles;
		euler.z = Random.Range(0, 360);
		data.position = @event.intersection;
		data.rotation = euler;
		data.size = size;
		data.color = color;

		index++;                 
	}

	public static void DisplayParticles() {
		for (int i = 0, l = data.Length; i < l; i++) {
			ParticleDecalData data = ParticleDecalManager.data[i];
			particles[i].position = data.position;
			particles[i].rotation3D = data.rotation;
			particles[i].startSize = data.size;
			particles[i].startColor = data.color;
		}
		
		particleSystem.SetParticles(particles, CAPACITY);
	}
}

[Serializable]
public class ParticleDecalData {

	public float size;
	public Vector3 position;
	public Vector3 rotation;
	public Color color;
}

目前看这个脚本可能还不太知其所以然。我也就先大概说下流程。Awake方法是一些初始化操作就不用细说了。ParticleDecalData这个类是保存每个血迹的相关数据的,包括大小,位置,旋转和颜色。我们设置CAPACITY这个血迹数最大值,如果出现超过CAPACITY的血迹数,就将相对的最早的一个血迹拿过来重新设置数据后使用,这是处于优化的考量。OnParticleHit方法是方便后面血迹碰撞检测脚本调用的,可以看到里面就调用了下SetParticleData方法。而这个方法可以很明显看出是用来设置血迹的相关数据的 – 位置,旋转,大小和颜色。最后的DisplayParticles方法是刷新粒子系统里的血迹数据用的。将这些方法设置为static是为了方便后面的碰撞检测脚本可以不需要获取实例就可以直接调用方法。

创建血液碰撞检测

这个问题我们需要先拿到游戏里做出血液飞溅效果的那个预置体Prefab。我们的做法是在这个预置体上挂一个粒子系统的碰撞检测脚本,当然了,也就需要对这个血液飞溅效果的粒子系统做一些设置。

  1. 勾选上碰撞Collision模块Module,展开。将Type选项设为WorldMode选项设为3D。至于Collision Quality的一系列选项就看你了,我建议是只对静态碰撞进行检测就行了。最后购选上Send Collision Messages即可,这样才能保证脚本里的OnParticleCollision方法会被正确回调。
  2. 创建一个碰撞检测脚本挂到预置体上,脚本内容如下:
using System.Collections.Generic;
using UnityEngine;

public class ParticleDecalController : MonoBehaviour {

	[Range(0f, 1f)]
	public float decalRate = 1f;
	public float minSize;
	public float maxSize;
	public Gradient colorGradient;
	
	private ParticleSystem _particleSystem;
	private readonly List<ParticleCollisionEvent> _collisionEvents = new List<ParticleCollisionEvent>(4);

	private void Awake() {
		_particleSystem = GetComponent<ParticleSystem>();
	}
	
	private void OnParticleCollision(GameObject other) {
		int count = _particleSystem.GetCollisionEvents(other, _collisionEvents);

		for (int i = 0; i < count; i++) {
			float r = Random.Range(0f, 1f);
			if (r <= decalRate) ParticleDecalManager.OnParticleHit(_collisionEvents[i], Random.Range(minSize, maxSize), colorGradient.Evaluate(r));
		}
		
		ParticleDecalManager.DisplayParticles();
	}
}

一个个都捋一遍。首先decalRate这个值代表当溅出的血液碰到物体表面后有多大的几率留下血迹。为什么不每个溅出的血液都画血迹呢?这是考虑到一般血液飞溅的粒子系统一次emission往往有30~60个粒子,这就意味着但是一次击打就要画出30~60滩血,太过密集,所以我们之画其中一部分。

下面的两个变量,minSizemaxSize控制着血迹的大小范围了,到时候在两者之间随机选择一个尺寸。最后一个变量colorGradient控制着颜色的变化范围,增加血迹的显示多样性。

最后重点说一下OnParticleCollision方法。这是一个内置的Unity Message。当这个预置体上的每帧粒子发生了碰撞的时候就会调用这个方法。传递的参数GameObject other则是检测到的碰撞体所在的GameObject。我们使用ParticleSystem.GetCollision(GameObject, List<ParticleCollisionEvent>)获得相关的碰撞事件。这里值得一提的是List<ParticleCollisionEvent>参数需要我们传进去一个已经初始化好的粒子碰撞事件列表。ParticleSystem.GetCollision(GameObject, List<ParticleCollisionEvent>)这个方法会将碰撞事件装入该列表中,同时会返回一个int值表示总共的碰撞事件个数,这里我们用count变量把个数缓存下来。

接下来我们遍历每个碰撞事件进行处理。首先我们随机出一个0~1之间的数,这个数我们先用它和decalRate比较来决定是否生成血迹,再用这个数确定血迹的颜色,即colorGradient.Evaluate(r)这一行。如果r <= decalRate就是需要绘制血迹的时候,我们调用ParticleDecalManager.OnParticleHit()方法,将相关的值传进去。

最后,当所有的血迹数据都设置后,我们调用ParticleDecalManager.DisplayParticles()方法对粒子系统的数据进行刷新。

大功告成!

效果展示

你自己试试呗,我就懒得再传一张图片到图床上了。

缺点

 老规矩,没有方法是完美无缺的,下面说下这个方法的缺点:

  1. 只能附着在静态的物体上,不能跟随动态物体。因为我们的血迹面片只在碰撞的时候设置了一下位置等数据,所以这个问题实属正常。一种可能的解决方案就是每帧对每个血迹面片的位置朝向等等进行更新。不过,效率就成问题了。所以我们也可以考虑创建两个血迹管理系统,一个负责静态场景上附着的血迹的管理,CAPACITY相对较大。另一个负责动态物体上血迹的管理,CAPACITY比较下,这样每帧更新起来快一点。
  2. 不一定能在不规则的表面正确显示,或者说可能会有血迹延伸出表面。这是因为我们只是粗略地将血迹面片的发现和碰撞的那个面的法线进行了统一,但是如果表面比较复杂,或者更简单,血迹生成在两个相交的表面之间,面片只能保证一个方向,也就是说不可能折叠起来贴和在两个表面上。
Recent Articles

Unity中时间尺度管理

概述 在这就是IB这个项目中,为了提高游戏的打击感,在玩家使用近战武器打进敌人身体里的时候,降低了时间尺度timeScale来表现肉体对于武器的阻碍——关于如何做好打击感我可能会在下篇文章说,这篇主要集中在Unity的时间上——这就导致了一个问题:如果武器同时打进了多个敌人并且这些肉体的阻碍程度不同时,时间该怎么调整?如果这时我们还有剧情演出需要改变时间尺度,我们又该以什么为准呢?问题主要问题我们已经提到一些了,归根结底还是处理不同时间尺度改变的请求之间的冲突。其实最好的方法应该...…

UnityContinue
Earlier Articles

48小时,一款游戏

概述 七月份逛论坛的时候意外发现了国外的一个游戏开发比赛Ludum Dare,了解了之后才知道这是目前游戏界最长寿的比赛之一,至今已经举办了十来年了。再一看,8月11号的时候就有一场比赛,于是一拍脑袋就参加了。机制Ludum Dare每次比赛分为两个赛组,一个是最传统的Compo,另一个是现在比较流行的Jam。Compo是Ludum Dare刚开始举办的时候就有的比赛机制,要求参赛人员独立一人在48小时内开发出一款符合当期比赛主题的游戏,其中除了代码可以使用第三方库以外,所...…

LudumDareContinue