1. 程式人生 > 實用技巧 >喵的Unity遊戲開發之路 - 物件複用

喵的Unity遊戲開發之路 - 物件複用

如果丟失格式、圖片或視訊,請檢視原文:https://mp.weixin.qq.com/s/pPQ8LTMfsM3J08_ACtu9MA

前言
很多童鞋沒有系統的Unity3D遊戲開發基礎,也不知道從何開始學。為此我們精選了一套國外優秀的Unity3D遊戲開發教程,翻譯整理後放送給大家,教您從零開始一步一步掌握Unity3D遊戲開發。本文不是廣告,不是推廣,是免費的純乾貨!本文全名:喵的Unity遊戲開發之路 - 物件管理-物件複用 - 物件池

銷燬形狀。

自動建立和銷燬。

構建一個簡單的GUI。

使用事件探查器跟蹤記憶體分配。

使用物件池回收形狀。

這是有關物件管理的系列教程中的第三篇。它增加了銷燬形狀的能力,然後提供了重用形狀的方法。

本教程使用Unity 2017.4.4f1製作。

銷燬物件

如果我們只能建立形狀,那麼它們的數量只能增加,直到開始新遊戲。但是幾乎總是在遊戲中建立某些東西時也可以將其銷燬。因此,讓我們有可能破壞形狀。

破壞的鑰匙

已經有一個建立形狀的關鍵點,因此新增一個關鍵點以銷燬它也很有意義。為此Game新增一個關鍵變數。儘管D似乎是一個合理的預設值,但它是用於移動的通用WASD鍵位配置的一部分。讓我們改用X,它是取消或終止的通用符號,在大多數鍵盤上都位於C旁邊。

  public KeyCode createKey = KeyCode.C;  public KeyCode destroyKey = KeyCode.X;

銷燬隨機形狀

給Game新增一種DestroyShape方法來照顧形狀的破壞。就像我們建立隨機形狀一樣,我們也會破壞隨機形狀。這是通過使用該Destroy方法為形狀列表選擇一個隨機索引並銷燬相應的物件來完成的。

void DestroyShape () {    int index = Random.Range(0, shapes.Count);    Destroy(shapes[index]);  }

但這僅在當前有形狀時才有效。可能不是這樣,可能是因為尚未建立或載入,或者所有現有的曾經都已被銷燬。因此,僅當列表包含至少一個形狀時,我們才能銷燬形狀。如果沒有,destroy命令將什麼都不做。

  void DestroyShape () {    if (shapes.Count > 0) {      int index = Random.Range(0, shapes.Count);      Destroy(shapes[index]);    }  } 

Destroy適用於遊戲物件,元件或資產。為了擺脫整個形狀物件,而不僅僅是其形狀的Shape組成部分,我們必須明確破壞該形狀組成部分的遊戲物件。我們可以通過元件的gameObject屬性訪問它。

    Destroy(shapes[index].gameObject); 

現在我們的DestroyShape方法已經可以使用了,當玩家按下銷燬鍵時Update呼叫它。

  void Update () {    if (Input.GetKeyDown(createKey)) {      CreateShape();    }    else if (Input.GetKeyDown(destroyKey)) {      DestroyShape();    }  } 

保持清單正確

現在,我們能夠建立和銷燬物件。但是,當嘗試破壞多個形狀時,您可能會得到一個錯誤:MissingReferenceException:型別'Shape'的物件已被破壞,但您仍在嘗試訪問它。

發生錯誤的原因是,儘管我們已經破壞了形狀,但尚未將其從shapes列表中刪除。因此,該列表仍然包含對銷燬遊戲物件的元件的引用。它們仍然以殭屍狀存在於記憶體中。再次嘗試銷燬此類物件時,Unity報告錯誤。

解決方案是正確擺脫對我們剛破壞的形狀的引用。因此,銷燬形狀後,將其從列表中刪除。這可以通過呼叫列表的RemoveAt方法來完成,將要刪除的元素的索引作為引數。

  void DestroyShape () {    if (shapes.Count > 0) {      int index = Random.Range(0, shapes.Count);      Destroy(shapes[index].gameObject);      shapes.RemoveAt(index);    }  } 

高效清除

儘管此方法有效,但這並不是從列表中刪除元素的最有效方法。由於列表是有序的,因此刪除一個元素會在列表中留下空白。從概念上講,這種差距很容易消除。被刪除元素的相鄰元素只是彼此成為鄰居。

但是,List該類是使用陣列實現的,因此不能直接操縱鄰居關係。而是通過將下一個元素移到該間隙中來消除該間隙,因此該間隙直接在該元素之後被移除的那個元件之後。這會將差距向列表末尾邁進了一步。重複此過程,直到差距超出列表的末尾。

但是,我們並不關心所跟蹤形狀的順序。因此,不需要元素的所有這種移動。儘管從技術上講我們無法避免,但我們可以通過手動抓住最後一個元素並將其放置在被破壞元素的位置來跳過幾乎所有工作,從而有效地將差距轉移到列表的末尾。然後我們刪除最後一個元素。

  void DestroyShape () {    if (shapes.Count > 0) {      int index = Random.Range(0, shapes.Count);      Destroy(shapes[index].gameObject);      int lastIndex = shapes.Count - 1;      shapes[index] = shapes[lastIndex];      shapes.RemoveAt(lastIndex);    }  } 

不斷的創造與破壞

一次建立和銷燬形狀並不是填充或填充遊戲的快速方法。如果我們要不斷建立和銷燬它們怎麼辦?我們可以通過一次又一次快速地按下按鍵來做到這一點,但這很快就會變得很煩人。因此,讓它自動化。

應該以什麼速度建立形狀?我們將使其可配置。這次我們不會通過檢查器進行控制。取而代之的是,我們將其設定為遊戲本身的一部分,以便玩家可以隨心所欲地改變速度。

圖形使用者介面

為了控制建立速度,我們將向場景新增圖形使用者介面(GUI)。GUI需要畫布,可以通過GameObject / UI / Canvas建立畫布。這將兩個新遊戲物件新增到場景中。首先是畫布本身,然後是一個事件系統,它可以與它進行互動。

這兩個物件都有多個組成部分,但是我們不必理會它們的細節。我們可以按原樣使用它們,而無需進行任何更改。預設情況下,畫布充當疊加層,在螢幕空間的遊戲視窗中的場景頂部渲染。

儘管從邏輯上講,螢幕空間畫布在3D空間中不存在,但它仍顯示在場景視窗中。這使我們可以對其進行編輯,但是在場景視窗處於3D模式時很難做到這一點。GUI與場景攝影機不對齊,其比例為每個畫素一個單位,因此它最終像場景中某個地方的巨大平面一樣。編輯GUI時,通常將場景視窗切換為2D模式,可以通過其工具欄左側的2D按鈕進行切換。

建立速度標籤

在新增用於建立速度的控制元件之前,我們將新增一個標籤,告訴播放器有關的內容。為此,我們通過GameObject / UI / Text新增文字物件並將其命名為Creation Speed Label。它會自動成為畫布的子代。實際上,如果沒有畫布,則在建立文字物件時會自動建立一個畫布。

GUI物件的功能類似於所有其他遊戲物件,除了它們具有Rect Transform元件之外,該元件擴充套件了常規的Transform元件。它不僅控制物件的位置,旋轉和比例,還控制其矩形大小,樞軸點和錨點。

錨點控制GUI物件如何相對於其父容器定位,以及它如何響應其父容器的大小變化。讓我們將標籤放在遊戲視窗的左上方。無論最終使用什麼視窗大小,要使其保持在該位置,請將其錨點設定在左上方。您可以通過單擊“錨點”正方形並選擇彈出的適當選項來執行此操作。還將顯示的文字更改為Creation Speed

將標籤放置在畫布的左上角,在標籤和遊戲視窗邊緣之間留一點空白。

創作速度滑塊

我們將使用滑塊控制建立速度。通過GameObject / UI / Slider新增一個。這將建立多個物件的層次結構,這些層次結構一起構成一個GUI滑塊小部件。將其本地根物件命名為Creation Speed Slider

將滑塊直接定位在標籤下方。預設情況下,它們具有相同的寬度,並且標籤在文字下方有足夠的空白空間。因此,您可以將滑塊向上拖動到標籤的底部邊緣,它將緊貼標籤邊緣。

Slider滑塊的本地根物件的元件具有許多設定,我們將保留它們的預設值。我們唯一要更改的是其“最大值”,它定義了最大建立速度,以每秒建立的形狀表示。讓我們將其設定為10。

設定創作速度

滑塊已經可以使用,您可以在播放模式下對其進行調整。但這還沒有任何影響。首先,我們必須給Game增加建立速度,因此需要進行一些更改。我們給它一個預設的公共CreationSpeed屬性。

public float CreationSpeed { get; set; }

滑塊的檢查器底部有一個“更改值(單個)”框。這表示在滑塊的值更改後被呼叫的方法或屬性的列表。On Value Changed後面的(單個)表示更改的值是浮點型。當前列表為空。通過單擊框底部的+按鈕更改此設定。

現在,事件列表包含一個條目。它具有三個配置選項。第一個設定控制何時應啟用此條目。預設情況下,將其設定為“僅執行時”。在其下方是用於設定應定位的遊戲物件的欄位。將對我們的Game物件的引用拖到其上。這使我們可以選擇附加到目標物件的元件的方法或屬性。現在,我們可以使用第三個下拉列表,選擇Game,然後在Dynamic float標頭下的頂部選擇CreationSpeed

我有一個零輸入欄位作為第四個選項?

從“ 靜態引數”列表中選擇CreationSpeed時,就會發生這種情況。顧名思義,它允許您配置固定值以用作引數,而不是動態滑塊值。您必須改為使用動態選項。

連續造型

為了使連續建立成為可能,我們必須跟蹤建立進度。Game為此新增一個float欄位。當該值達到1時,應建立一個新形狀。

float creationProgress;

Update通過新增自上一幀以來經過的時間來增加進度Time.deltaTime。通過將時間增量乘以建立速度來控制進度的快慢。

  1. void Update () {
  2. creationProgress += Time.deltaTime * CreationSpeed; }

每次creationProgress達到1時,我們都必須將其重置為零並建立形狀。

    creationProgress += Time.deltaTime * CreationSpeed;    if (creationProgress == 1f) {      creationProgress = 0f;      CreateShape();    }

但是,最終獲得的進度值恰好是1的可能性很小。相反,我們會超出一定數量。因此,我們應該檢查是否至少有1個。然後我們將進度減少1,節省額外的進度。因此,時間安排不準確,但我們不會丟棄額外的進度。

    creationProgress += Time.deltaTime * CreationSpeed;    if (creationProgress>=1f) {      creationProgress-= 1f;      CreateShape();    } 

但是,自上一幀以來,我們可能取得了很大進步,最終我們得出的值為2、3甚至更大。這可能會在幀速率下降期間以及高建立速度的情況下發生。為了確保我們儘快趕上,請將if語句更改為while語句。

    creationProgress += Time.deltaTime * CreationSpeed;    while(creationProgress >= 1f) {      creationProgress -= 1f;      CreateShape();    } 

現在,您可以讓遊戲以所需的速度建立規則的新形狀流,速度高達每秒十個形狀。如果要關閉自動建立過程,只需將滑塊設定回零即可。

連續形狀破壞

接下來,重複我們對建立滑塊所做的所有工作,但現在對銷燬滑塊進行重複。建立另一個標籤和滑塊,最快的方法是複製現有標籤和滑塊,將其向下移動並重命名。

然後新增一個DestructionSpeed屬性並將破壞滑塊連線到該屬性。如果複製了建立滑塊,則只需更改其定位的屬性。

public float DestructionSpeed { get; set; }

最後,新增程式碼以跟蹤破壞進度。

  1. float creationProgress, destructionProgress;
  2. void Update () {
  3. creationProgress += Time.deltaTime * CreationSpeed; while (creationProgress >= 1f) { creationProgress -= 1f; CreateShape(); }
  4. destructionProgress += Time.deltaTime * DestructionSpeed; while (destructionProgress >= 1f) { destructionProgress -= 1f; DestroyShape(); } }

遊戲現在可以同時自動建立和銷燬形狀。如果將兩者設定為相同的速度,則形狀的數量將大致保持不變。要以令人愉悅的方式使建立和銷燬同步,可以稍微調整一個速度,直到它們的進度對齊或交替。

以最快的速度建立和銷燬。

如何擺脫場景視窗中的畫布?

當不在GUI上工作時,將畫布顯示在場景視窗中可能會很煩人。您可以通過編輯器右上角的“ 層”選單將其(或特定層上的任何其他層)隱藏。預設情況下,所有GUI物件都位於UI層上,您可以通過切換其眼睛按鈕使其不可見。這會影響場景視窗,但不會影響遊戲視窗。

物件池

每次例項化物件時,都必須分配記憶體。而且,每當物件被銷燬時,就必須回收其使用的記憶體。但是開墾並不會立即發生。有一個垃圾收集過程有時會執行以清理所有內容。這是一個昂貴的過程,因為它必須根據是否還有任何引用來確定哪些物件實際上不再有效地存在。因此,已使用的記憶體量會增長一段時間,直到被認為很多為止,然後無法訪問的記憶體將被識別並再次可用。如果涉及許多記憶體塊,這可能會導致遊戲中的顯著幀頻下降。

雖然重用低階記憶體很困難,但在更高級別重用物件要容易得多。如果我們從不銷燬遊戲物件,而是回收它們,那麼垃圾收集過程就不需要執行。

剖析

要了解發生多少記憶體分配以及何時進行分配,可以使用Unity的探查器視窗,您可以根據Unity版本通過Window / ProfilerWindow / Analysis / Profiler開啟該視窗。在播放模式下,它可以記錄很多資訊,包括CPU和記憶體使用情況。

在積累了一些形狀之後,讓遊戲以最大的建立和破壞速度執行一段時間。然後在探查器的資料圖上選擇一個點,這將暫停遊戲。選擇“ CPU”部分時,所選框架的所有高階呼叫均顯示在圖形下方。您可以按記憶體分配對呼叫進行排序,這在GC Alloc列中顯示。

在大多數幀中,總分配為零。但是,當在該幀中例項化形狀時,您會在頂部看到一個分配記憶體的條目。您可以展開該條目以檢視Game.Update負責例項化的呼叫。

在執行期間,編輯器中分配的位元組數可以不同。遊戲沒有像獨立版本那樣進行優化,編輯器本身也會影響分析。通過建立獨立的開發構建,並使其自動連線到編輯器進行概要分析,可以獲得更好的資料。

建立內部版本,執行一段時間,然後在編輯器中檢查探查器資料。

儘管我們仍在與必須收集和傳送概要分析資料的開發版本一起工作,但是此概要分析資料不受編輯器的影響。

回收利用

由於我們的形狀是簡單的遊戲物件,因此不需要太多記憶體。儘管如此,持續不斷的新例項化流最終將觸發垃圾回收過程。為防止這種情況,我們必須重用形狀而不是破壞形狀。因此,每次遊戲破壞形狀時,我們都應該將它們退回工廠進行回收。

回收形狀是可行的,因為它們在使用時不會發生太大變化。他們得到隨機的變換,材質和顏色。如果進行了更復雜的調整(例如新增或刪除元件或新增子物件),那麼回收將不可行。為了同時支援這兩種情況,讓我們新增一個切換開關ShapeFactory來控制是否回收。當前我們的遊戲可以回收,因此請通過檢查器啟用它。

[SerializeField]  bool recycle;

合併形狀

回收形狀時,我們將其放入備用池中。然後,當要求提供新形狀時,我們可以從該池中獲取現有形狀,而不是預設情況下建立新形狀。僅當池為空時,我們才必須例項化新形狀。對於工廠可以生產的每種形狀型別,我們都需要一個單獨的池,因此為它提供一個形狀列表陣列。


  1. using System.Collections.Generic;using UnityEngine;
  2. [CreateAssetMenu]public class ShapeFactory : ScriptableObject {
  3. List<Shape>[] pools;
  4. }

新增一個建立池的方法,該方法只是prefabs陣列中每個條目的一個空列表。

void CreatePools () {    pools = new List<Shape>[prefabs.Length];    for (int i = 0; i < pools.Length; i++) {      pools[i] = new List<Shape>();    }  }

Get方法開始時,請檢查是否啟用了回收。如果是,請檢查池是否存在。如果不是,則在此時建立池。

  public Shape Get (int shapeId = 0, int materialId = 0) {    if (recycle) {      if (pools == null) {        CreatePools();      }    }    Shape instance = Instantiate(prefabs[shapeId]);    instance.ShapeId = shapeId;    instance.SetMaterial(materials[materialId], materialId);    return instance;  } 

從池中檢索物件

例項化形狀並設定其ID的現有程式碼現在僅應在不回收的情況下使用。否則,應從池中檢索例項。為了使之成為可能,instance必須在決定如何獲取例項之前宣告該變數。

Shape instance;    if (recycle) {      if (pools == null) {        CreatePools();      }    }    else {      instance= Instantiate(prefabs[shapeId]);      instance.ShapeId = shapeId;    }        instance.SetMaterial(materials[materialId], materialId); 

啟用回收功能後,我們必須從正確的池中提取例項。我們可以使用形狀ID作為池索引。然後從該池中獲取一個元素,然後將其啟用。這是通過呼叫SetActive其遊戲物件上的方法true作為引數來完成的。然後將其從池中刪除。由於我們不在乎池中元素的順序,因此我們只需獲取最有效的最後一個元素即可。

    Shape instance;    if (recycle) {      if (pools == null) {        CreatePools();      }      List<Shape> pool = pools[shapeId];      int lastIndex = pool.Count - 1;      instance = pool[lastIndex];      instance.gameObject.SetActive(true);      pool.RemoveAt(lastIndex);    }    else {      instance = Instantiate(prefabs[shapeId]);    } 

但這僅在池中有東西時才有可能,因此請檢查一下。

      List<Shape> pool = pools[shapeId];      int lastIndex = pool.Count - 1;      if (lastIndex >= 0) {        instance = pool[lastIndex];        instance.gameObject.SetActive(true);        pool.RemoveAt(lastIndex);      }

如果沒有,我們別無選擇,只能建立一個新的形狀例項。

      if (lastIndex >= 0) {        instance = pool[lastIndex];        instance.gameObject.SetActive(true);        pool.RemoveAt(lastIndex);      }      else {        instance = Instantiate(prefabs[shapeId]);        instance.ShapeId = shapeId;      }

為什麼要使用列表而不是堆疊?

因為列表在播放模式下可以重新編譯,而堆疊則不能。Unity不會序列化堆疊。您可以改用堆疊,但是列表可以正常工作。

回收物件

要使用這些池,工廠必須有一種方法來回收不再需要的形狀。這可以通過新增帶有shape引數的公共方法Reclaim來完成。此方法還應首先檢查是否啟用了回收,如果啟用了回收,則在執行其他任何操作之前,請確保池已存在。

public void Reclaim (Shape shapeToRecycle) {    if (recycle) {      if (pools == null) {        CreatePools();      }    }  }

在Get建立池還不夠嗎?

如果從未在播放模式下切換回收,那確實足夠了,因為必須先檢索形狀,然後才能對其進行回收。通過同樣執行此操作,Reclaim可以在播放模式下切換回收,這使得更容易進行實驗。

現在我們確定這些池已存在,可以通過將回收的形狀使用其形狀ID作為池索引來將其新增到正確的池中。

  public void Reclaim (Shape shapeToRecycle) {    if (recycle) {      if (pools == null) {        CreatePools();      }      pools[shapeToRecycle.ShapeId].Add(shapeToRecycle);    }  } 

同樣,必須停用回收的形狀,這現在代表破壞。

      pools[shapeToRecycle.ShapeId].Add(shapeToRecycle);      shapeToRecycle.gameObject.SetActive(false);

但是,如果未啟用回收功能,則應將形狀破壞為真實形狀。

    if (recycle) {    }    else {      Destroy(shapeToRecycle.gameObject);    }

回收而不是銷燬

工廠無法強制要求將形狀返回給它。它是由Game使回收可能,通過呼叫Reclaim,而不是DestroyDestroyShape

  void DestroyShape () {    if (shapes.Count > 0) {      int index = Random.Range(0, shapes.Count);      //Destroy(shapes[index].gameObject);      shapeFactory.Reclaim(shapes[index]);      int lastIndex = shapes.Count - 1;      shapes[index] = shapes[lastIndex];      shapes.RemoveAt(lastIndex);    }  } 

以及開始新遊戲時。

  void BeginNewGame () {    for (int i = 0; i < shapes.Count; i++) {      //Destroy(shapes[i].gameObject);      shapeFactory.Reclaim(shapes[i]);    }    shapes.Clear();  } 

確保Game播放效果良好,並且在將形狀歸還後仍不會破壞形狀。那會導致錯誤。因此,這不是一個簡單的技術,程式設計師必須表現出來。僅將從工廠獲得的形狀退還給它,而無需對其進行重大更改。儘管可以破壞形狀,但無法回收。

行動中的回收

儘管無論是否啟用回收功能,遊戲都仍然發揮相同的作用,但是您可以通過觀察層次結構視窗來看到差異。當建立和銷燬以相同的速度發生時,您會看到形狀將變為活動和不活動狀態,而不是被建立和銷燬。一段時間後,遊戲物件的總量將變得穩定。僅當特定形狀型別的池為空時,才會建立新例項。除非建立速度高於銷燬速度,否則遊戲執行的時間越長,發生頻率就越低。

您還可以使用事件探查器來驗證記憶體分配發生的頻率要少得多。尚未完全消除它們,因為有時仍必須建立新形狀。另外,有時回收物件時會分配記憶體。發生這種情況有兩個原因。首先,池列表有時需要增長。其次,要停用物件,我們必須訪問該gameObject屬性。在屬性第一次檢索對遊戲物件的引用時,這會分配一點記憶體。因此,只有在每種形狀第一次被回收時才會發生這種情況。

下一個教程是“多場景”。

資源庫(Repository)

https://bitbucket.org/catlikecodingunitytutorials/object-management-03-reusing-objects


往期精選

Unity3D遊戲開發中100+效果的實現和原始碼大全 - 收藏起來肯定用得著

S‍‍‍‍hader學習應該如何切入?

喵的Unity遊戲開發之路 - 從入門到精通的學習線路和全教程‍‍‍‍


宣告:釋出此文是出於傳遞更多知識以供交流學習之目的。若有來源標註錯誤或侵犯了您的合法權益,請作者持權屬證明與我們聯絡,我們將及時更正、刪除,謝謝。

原作者:Jasper Flick

原文:

https://catlikecoding.com/unity/tutorials/object-management/reusing-objects/

翻譯、編輯、整理:MarsZhou


More:【微信公眾號】u3dnotes