打飞碟——Unity之对象池

打飞碟------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 -

相关推荐
WujieLi3 天前
独立开发沉思录周刊:vol18.AI 正在成为无处不在的基础设施
程序员·设计·创业
一直学习永不止步7 天前
LeetCode题练习与总结:设计推特--355
java·算法·leetcode·链表·设计·哈希表·堆(优先队列)
YesPMP2510 天前
未来已来:3D建模技术引领就业新趋势
3d建模·动画·设计·特效·电商·教育
WujieLi11 天前
独立开发沉思录周刊:vol17.没有目标的成长
人工智能·产品·设计
长沙红胖子Qt14 天前
硬件开发笔记(三十):TPS54331电源设计(三):设计好的原理图转设计PCB布板,12V输入电路布局设计
开源·产品·设计
艾迪的技术之路18 天前
DDD是什么?怎么使用?
后端·架构·设计
梓羽玩Python24 天前
今日软荐:一款专注于 3D 手机 Mockup 展示的工具-Rotato!
前端·github·设计
AokSend邮件API珠25 天前
免费企业邮箱有什么推荐测试的?烽火邮箱评价如何
产品·设计
穷人小水滴1 个月前
交通工具等物体的 3D 建模 (科幻游戏 《外卖员模拟器》)
游戏·3d·blender·设计·建模·科幻
AokSend邮件API珠1 个月前
最好的10个在线接收短信验证码平台服务商
产品·设计