はじめに
様々な言語で「デザインパターン」の本が世の中にありますが、筆者個人の経験では
いまいちピンとこない例
いまいちピンとこないコード
で説明されてることが多く、
結局これっていつ使うの?
という疑問に答えるには仕事仲間等との議論をしないと
辿り着けないことが多々ありました。
そこで特に「ゲーム開発ではどう使うか?」にフォーカスを当てて、実践的な例を交えて
デザインパターンの説明の需要があると思い記事を作りました。
デザインパターンを学ぶ理由
デザインパターンを学ぶ理由としては
- 車輪の再発明の防止
- 長文で読みにくいコード(可読性の低いコード)を減らす
- コードを疎結合にして変更に強くなる(変更時のコスト・変更箇所を減らす)
- モジュールとして使いまわせるように、コードの再利用性を高める
といった効果を期待できます。
対象読者
Unity 全くの初心者(インストールしただけで触ったことがないような方)はお断りです。
最低限以下のことは理解・経験を積んでおくことが必須になります。
- MonoBehaviour 継承クラスでコードを書いたことがある
- C# のピュアクラスを用いた自作クラスを作ったことがある
- クラスの継承という概念は知っている
そのため、脱・初心者
中級者へのステップアップ
として デザインパターンを学ぶ
のが良いと思います。
デザパタ記事リンク
生成系
構造系
様態・ふるまい系
- Chain of Responsibility パターン
- Command パターン
- Interpreter パターン
- Iterator パターン
- Mediator パターン
- Memento パターン
- Observer パターン
- State パターン
- Strategy パターン
- TemplateMethod パターン
- Visitor パターン
Flyweight パターンについて
Flyweight はボクシングのフライ級(下から3番目に軽量)のことで、ここから転じて Flyweightパターンとは
インスタンスを共有してリソースの無駄をなくして軽量化しようぜ!というデザインパターンです。
抽象的で非常にわかりづらいですが、簡単に言うと GetOrAdd()
のAPIを実現する仕組みのことです。
C# における Flyweight パターン
C#/Unity におけるFlyweightパターンで有名なところでいうと、Cy#の河合さんの記事にある string.Intern()
が有名なところでしょう。
こちらはstring クラスが提供するメソッドでざっくりと言えば string.Intern(string str) = string GetOrAdd(string newText)
です。
string クラス(正確には共通言語ランタイム)では、インターンプールと呼ばれるテーブルを内部で持っており、文字列リテラル("hoge", 'c' とか直接的な文字列)が生成されるタイミングで内部に登録されます。
そして、このInternメソッドは内部のテーブルに存在すればそのメモリの文字列を返し、なければ新規登録という挙動をします。
つまり、Flyweight パターンを実現したそのものといえ、実際に共通のアドレスを指すことで無駄にメモリを増やさないようにしています。
ゲームにおける Flyweight
上記の例に加えて、動的に個数を増やすようなObjectPool だとFlyweightパターンを実現しているパターンもあります。
※基本的にObjectPoolはパフォーマンスを出すために予め 固定数
のObjectを生成し、それを使い回すことが大半です。
ダミーコード的には以下のような形です。
using System;
using System.Collections.Generic;
using UnityEngine;
public abstract class DynamicObjectPool<T>
{
#region ===== CONSTS =====
protected const int DEFAULT_POOL_COUNT = 10;
#endregion //) ===== CONSTS =====
#region ===== MEMBER_VARIABLES =====
/// <summary>
/// このObjectPoolで生成したアイテム全て
/// </summary>
protected List<T> m_allItems = null;
/// <summary>
/// オブジェクトプール置き場
/// </summary>
protected Queue<T> m_pool = null;
/// <summary>
/// 利用可能なアイテム数
/// </summary>
public int CurrentPoolCount => m_pool?.Count ?? 0;
private int m_poolSize = DEFAULT_POOL_COUNT;
/// <summary>
/// プールのサイズ
/// </summary>
public int MaxPoolSize => m_poolSize;
#endregion //) ===== MEMBER_VARIABLES =====
#region ===== INITIALIZE =====
protected void InitializePool(int _itemCount = DEFAULT_POOL_COUNT)
{
m_poolSize = Mathf.Max(0, _itemCount);
Debug.Assert(m_poolSize > 0);
m_pool = new Queue<T>(m_poolSize);
m_allItems = new List<T>(m_poolSize);
GeneratePoolItem(m_pool, m_allItems, m_poolSize);
}
protected virtual void GeneratePoolItem(Queue<T> _queue, List<T> _itemList, int _itemCount)
{
Debug.Assert(_itemCount > 0);
if (_itemCount < 1)
{
return;
}
for (int i = 0; i < _itemCount; i++)
{
var item = CreateItem();
// 意図しない動作を起こさないようにDisable状態で生成する
item.SetDisable();
_queue.Enqueue(item);
_itemList.Add(item);
}
}
/// <summary>
/// ObjectPool のリサイズ(Capacity は変更しない )
/// </summary>
/// <param name="_newSize"></param>
public void ResizePoolSize(int _newSize)
{
if (_newSize < 1)
{
return;
}
if (MaxPoolSize > _newSize)
{
while (m_pool.Count > _newSize)
{
var item = m_pool.Dequeue();
item?.Dispose();
m_allItems.Remove(item);
}
}
else
{
/*
*Capacityに応じての容量増加のため
* 無駄に増やさないように極力呼ばない設計を推奨
*/
GeneratePoolItem(m_pool, m_allItems, _newSize - MaxPoolSize);
}
m_poolSize = m_allItems.Count;
}
#endregion //) ===== INITIALIZE =====
/// <summary>
/// Item生成
/// </summary>
/// <returns></returns>
public abstract T CreateItem();
/// <summary>
/// PoolからItemを拝借
/// </summary>
/// <returns>T 型のItem or NULL </returns>
public T Rent()
{
Debug.Assert(CurrentPoolCount > 0, "Empty Pool !! ExpandQueueSize");
if (CurrentPoolCount < 1)
{
ResizePoolSize(MaxPoolSize + 1);
}
T item = m_pool.Dequeue();
Debug.Assert(item != null);
if (item != null)
{
OnBeforeRent(item);
}
return item;
}
/// <summary>
/// Poolへ返却
/// </summary>
public void Return(T _item)
{
if (_item == null)
{
return;
}
// 返却前の処理
OnBeforeReturn(_item);
if (!m_pool.Contains(_item))
{
m_pool.Enqueue(_item);
}
}
(略)
}
上記のRent() の部分でObjectPoolから取得しますが
if (CurrentPoolCount < 1)
{
ResizePoolSize(MaxPoolSize + 1);
}
としてあげることで、個数がどうしても足りない場合新規に作成するため、これのおかげでGetOrAdd(GetOrCreate) を実現している形です。
まとめ
ゲームでは パフォーマンスを稼ぐ
ための必須テクニックといっても過言ではありません。
特に Nintendo Switch
や スマートフォン
, WebGL
向けゲームだと、メモリがカツカツな場合があり、パフォーマンスに無頓着だと、プレイ中に突然のクラッシュや、そもそも30FPSもまともに出ないような状態に遭遇しがちです。
上記のように開発に支障が出たり、悪いUXを出さないようにするためにも日頃からパフォーマンスの悪いコードを書いてないかは意識づけると、より良いエンジニアへステップアップできるでしょう。