打飞碟------Unity之对象池
对象池技术简介
在游戏过程中,可能会遇到这样的问题:在横版射击游戏和PVE等游戏中的各种特效对象以及重复对象,需要在程序中动态地、频繁地创建与销毁游戏对象,老鸟看到"动态"二字直接就应激反应了:忘记回收对象可能导致无用对象的数量不断增长最终吃光设备资源,或者产生各种不期望的效果。
既然如此,一种很直接的想法就是限制游戏对象的数量 ,使用全局变量记录游戏对象的数量并维护它,这确实是一个防止游戏对象无限复制的好方法,但是它并没有多少技术可言,因为它只是限制了游戏消耗资源的上限,并没有优化游戏资源的分配,即没有充分利用"重复"这一额外信息。那么既然游戏对象需要频繁地创建与销毁,且同类游戏对象很可能只在某些属性上有差异,实际上都是某种预制件的实例,那么不如从一开始就创建出足够数量的同类游戏对象,并将它们放在仓库(对象池)中,当需要此类对象时就从其中取出一个(申请资源),并在不需要的时候再放回去(回收资源)就OK了,节省了游戏对象创建与销毁的额外开销。
综上,对象池 = 动态资源创建 + 回收。对象数量限制是不一定需要的,它限制了系统的灵活性,就像"漏保"。
那么接下来就结合打飞碟这一游戏案例,浅浅一探对象池技术的应用。
游戏案例
游戏内容
游戏有 n 个 round,每个 round 都包括10 次 trial。
每个 trial 的飞碟的大小、发射位置、速度、角度、同时出现的个数都可能不同。
每个 trial 的飞碟有随机性,总体难度随 round 上升。
鼠标点中得分,得分规则按大小、速度不同计算,规则可自由设定。
对象池设计
下面给出飞碟对象池的设计图:
下面是各个部分的代码
C#
// UFOAttributes.cs
using UnityEngine;
[System.Serializable]
public class UFOAttributes_
{
[Tooltip("飞船颜色")]
public Color color;
[Tooltip("移动速度")]
public float speed;
[Tooltip("伤害量")]
public int damage;
[Tooltip("缩放")]
public float scale;
[Tooltip("生成位置偏移量")]
public float offset;
}
[CreateAssetMenu(fileName = "UFOInstance", menuName = "(ScriptableObject)UFOAttributes")]
public class UFOAttributes : ScriptableObject
{
[Tooltip("飞船属性")]
public UFOAttributes_ attributes;
[Tooltip("击落积分")]
public int score;
}
C#
//UFO.cs
using UnityEngine;
public class UFO : MonoBehaviour
{
/// <summary>
/// UFO的基本属性
/// </summary>
private UFOAttributes attr;
public UFOAttributes Attribute {
get {return attr;}
set {
attr = value;
float scale = Attribute.attributes.scale;
GetComponent<Transform>().localScale = new Vector3(scale, scale, scale);
}
}
}
重点来了!对象池实现代码:
C#
//DiskFactory.cs
using System.Collections.Generic;
using UnityEngine;
public class DiskFactory : MonoBehaviour
{
// 飞碟模型
public GameObject UFOPrefab;
private List<GameObject> used = new(), prepared = new();
/// <summary>
/// 生成飞碟
/// </summary>
public GameObject GetUFO()
{
GameObject ufo;
if(prepared.Count == 0) {
ufo = Instantiate(UFOPrefab);
...
prepared.Add(ufo);
}
ufo = prepared[0];
...
used.Add(ufo);
prepared.RemoveAt(0);
ufo.SetActive(true);
return ufo;
}
/// <summary>
/// 回收飞碟
/// </summary>
public void FreeUFO(GameObject gameObject) {
if(used.IndexOf(gameObject) == -1) {
throw new System.Exception("Unable to free UFO object: Target not in the used list.");
} else {
used.Remove(gameObject);
prepared.Add(gameObject);
gameObject.SetActive(false);
}
}
}
在这里对象池没有设置数量上限,而是在需要新的游戏对象且空闲对象不足时创建一个新的(16~20行)。需要注意的是,空闲的游戏对象不要只将它回收到链表中就完事了,这样Unity引擎仍然会为其进行渲染!它还在占用资源!还会去到奇怪的地方!因此需要调用SetActive方法(第25、37行)来告诉Unity引擎,不再需要为空闲的对象池进行渲染,节省更多的资源。
Singleton用于获取游戏中的唯一实例对象:
C#
//Singleton.cs
using UnityEngine;
public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
protected static T instance;
public static T Instance
{
get
{
if (instance == null)
{
instance = (T)FindObjectOfType(typeof(T));
if (instance == null)
{
Debug.LogError("An instance of " + typeof(T) +
" is needed in the scene, but there is none.");
}
}
return instance;
}
}
}
这里单实例对象的意义:游戏内只能出现一个对象池,在需要使用对象池的地方就使用代码Singleton<DiskFactory>.Instance
来获取对象池对象。
单实例对象可以通过将其挂载在一个游戏内的唯一游戏对象来实现,而游戏内的唯一对象则可通过将以下脚本挂载到其上来实现,具体逻辑是使用GlobalControl的静态成员instance记录第一个挂载了GlobalControl的游戏对象(第12行),当有其他挂载了GlobalControl的游戏对象被创建时将会自毁(7~9行):
C#
//GlobalControl.cs
using UnityEngine;
public class GlobalControl : MonoBehaviour {
protected static GlobalControl instance;
void Awake() {
if(instance != null) {
Destroy(gameObject);
}
else {
DontDestroyOnLoad(gameObject);
instance = this;
}
}
}
物理动作管理
这里的游戏系统使用了上一篇"牧师与恶魔"中的动作管理与场景控制分离的架构,不同在于这里使用了物理引擎管理飞碟的物理动作。
这里终于体现了继承与多态的代码复用的优势了,仅需要在原有的MoveAction以及ActionManager之上稍加修改就可,这里仅给出PhysicalMoveAction的实现:
C#
//PhysicalMoveAction
using System;
using UnityEngine;
public class PhysicalMoveAction : AbstractAction
{
private Vector3 TargetPosition;
private Rigidbody rigidbody;
private float Speed;
public static PhysicalMoveAction CreateMoveAction(Vector3 target, float speed)
{
PhysicalMoveAction result = CreateInstance<PhysicalMoveAction>();
result.TargetPosition = target;
result.Speed = speed;
return result;
}
public override void Start()
{
rigidbody = Executor.GetComponent<Rigidbody>();
rigidbody.AddForce((TargetPosition - Transform.position).normalized * Speed * 1000, ForceMode.Impulse);
}
public override void Update()
{
if (!destroy)
{
if (Vector3.Distance(TargetPosition, Transform.position) <= 0.1)
{
Callback.AfterExecution(this);
destroy = true;
return;
}
// 刚体的实际速度向量
Vector3 speedVec = rigidbody.velocity;
// 目标向量
Vector3 target = (TargetPosition - Transform.position).normalized;
// 刚体速度向量在目标方向上的分量
Vector3 toward = Vector3.Dot(speedVec, target) * target;
// 刚体速度向量在垂直于目标方向上的分量
Vector3 translate = speedVec - toward;
if (Vector3.Angle(target, speedVec) > 90)
translate = speedVec + toward;
if (translate != Vector3.zero)
{
// 存在平移方向的分量
float m = translate.magnitude;
// 衰减率β = 249/250
m = Math.Max(m - Math.Max(0.0001f, m / 250), 0);
translate = translate.normalized * m;
}
toward = toward.normalized * Speed * 1000;
rigidbody.velocity = toward + translate;
}
}
}
如果希望保留飞碟之间的碰撞效果,但是这样会对飞碟飞往目标位置的动作造成影响(可能被别的飞碟碰飞从而无法到达目标位置,进而导致移动动作永远也无法完成),这实际上是一个运动学(物理)问题,这里的处理分为两步:
- 衰减垂直于目标方向的速度向量并恢复目标方向上的速度向量(第32~51行)
- 对于超出屏幕区域的飞碟,直接判定为未击中(下面8~22行)
C#
//SceneController.cs
using UnityEngine;
using System.Collections.Generic;
using System;
public class SceneController : MonoBehaviour, ISceneController, IUserAction
{
...
void Update() {
if(!camera) {
camera = Camera.main;
rect = new Rect(0, 0, Screen.width, Screen.height);
}
foreach (GameObject item in Objects)
{
if(!rect.Contains(camera.WorldToScreenPoint(item.GetComponent<Transform>().position))) {
// 飞碟飞出屏幕区域
ActionManager.CancleExecution(item);
NotHit(item);
break;
}
}
}
...
}
这里使用了Camera.WorldToScreenPoint方法(第15行),它的作用是将游戏中的坐标映射到屏幕坐标,因此使用一个Rect记录当前的屏幕区域,并用Rect.Contains方法判断游戏物体是否在屏幕之外。
鼠标拾取物体
Camera.ScreenPointToRay方法能够根据屏幕上的一点与摄像机位置确定唯一的射线,用这个方法就能实现检测鼠标是否点击到飞碟(下面12~19行):
C#
//UserGUI.cs
using UnityEngine;
public class UserGUI : MonoBehaviour {
...
void Fire() {
Camera camera;
if(this.camera)
camera = this.camera.GetComponent<Camera>();
else
camera = Camera.main;
Ray ray = camera.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit))
{
if (hit.collider.gameObject.tag.Contains("Finish"))
{
action.Hit(hit.transform.gameObject);
}
}
}
}
注:这里通过射线与目标物体的碰撞判断鼠标是否点击到飞碟,因此需要为飞碟添加碰撞体组件。
设计汇总
最后给出系统的实体类图:
- fin -