はじめに
Unityでゲーム開発をしている時、以下のような悩みに直面したことはありませんか?
- GameObjectに紐づく大量のデータ管理を効率よく行いたい。
-
GetComponent<T>
のパフォーマンスがボトルネックになる。 - Job Systemを使いたいけど、データ構造の変換が面倒!
今回はこれらの問題を解決する、ハッシュテーブル付きのデータコンテナを開発しました。
正確には管理したいデータ型に応じて、Source Generatorでデータの入れ物を自動生成するツールなのですが。
ともかく、このパッケージを使うと以下のような効果が期待できます。
- スタック上のメモリレイアウトの最適化。
- JobSystem導入を簡易化。
- (ほぼ)ゼロアロケーションのデータ管理。
- GetComponent()の撲滅。
具体的な使用ケース
基本的に、Unityのゲームはコンポーネントを持ったオブジェクトにより構成されています。(ECSを除く)
よってゲームコードの中でオブジェクトに紐づいたデータ、そしてコンポーネントにアクセスする機会が非常に多いです。
GetComponent<T>()
もその一例ですね。
そして、このオブジェクト単位でデータ管理をする場合、以下のようなGameObjectをキーにしたDictionaryを作る機会があると思います。
データ管理コード(展開)
// 敵のデータ管理クラス
public class EnemyManager : MonoBehaviour
{
// GameObjectをキーにした各種Dictionary
private Dictionary<GameObject, EnemyHealth> healthDict = new();
private Dictionary<GameObject, EnemyMovement> movementDict = new();
private Dictionary<GameObject, EnemyAI> aiDict = new();
// 連続アクセス用のList(Dictionaryと同期が必要)
private List<EnemyHealth> healthList = new();
private List<EnemyMovement> movementList = new();
private List<EnemyAI> enemyAI = new();
// 敵を追加
public void AddEnemy(GameObject enemy)
{
var health = new EnemyHealth { hp = 100 };
var movement = new EnemyMovement { speed = 5f };
var ai = enemy.GetComponent<EnemyAI>();
// Dictionaryに追加
healthDict.Add(enemy, health);
movementDict.Add(enemy, movement);
aiDict.Add(enemy, ai);
// Listにも追加(同期が必要)
healthList.Add(health);
movementList.Add(movement);
enemyAI.Add(ai);
}
// 特定の敵にダメージ
public void DamageEnemy(GameObject enemy, float damage)
{
if ( healthDict.TryGetValue(enemy, out var health) )
{
health.hp -= damage;
if ( health.hp <= 0 )
{
RemoveEnemy(enemy);
}
}
}
// 敵を削除
private void RemoveEnemy(GameObject enemy)
{
if ( healthDict.ContainsKey(enemy) )
{
// インデックスを探す
int index = enemyAI.IndexOf(aiDict[enemy]);
// 各Dictionaryから削除
healthDict.Remove(enemy);
movementDict.Remove(enemy);
aiDict.Remove(enemy);
// 各Listからも削除(順序を保つ必要がある)
healthList.RemoveAt(index);
movementList.RemoveAt(index);
enemyAI.RemoveAt(index);
Destroy(enemy);
}
}
// 全敵の更新(連続アクセス)
void Update()
{
// Listを使って連続アクセス
for ( int i = 0; i < healthList.Count; i++ )
{
var health = healthList[i];
// AIが持つ自分のHP割合を更新
enemyAI[i].hpRate = health.hp / health.maxHp;
}
}
}
このようなコードが必要な理由は以下です。
- ゲームオブジェクトに付属するデータを
GetComponent<T>
なしで取得するため。 - UnsafeListやListを使ってデータに連続アクセス + JobSystemを使用するため。
しかしこのコードには以下のような問題があります。
- 同期が大変 - DictionaryとListの両方を更新する必要がある
- 削除が複雑 - インデックスを探して各コレクションから削除
- メモリ効率が悪い - 同じデータを複数箇所で管理
- バグの温床 - 同期ミスでデータ不整合が発生しやすい
そしてこうしたコードを効率化 + 強化するのが今回のパッケージです。
使い方は簡単で、アトリビュートを付けた空っぽのクラスを定義するだけでOKです。
SourceGeneraterでデータ管理用のpartialクラスが作成されて、次のようなシンプルなコードでデータ管理ができるようになります。
Source Generatorを使った場合(展開して比較)
// アトリビュートで管理したい型を指定すると、該当のデータ型専用のコンテナが生成される。
[ContainerSetting(
structType: new[] { typeof(EnemyHealth), typeof(EnemyMovement) },
classType: new[] { typeof(EnemyAI) }
)]
public partial class EnemyContainer
{
// 基本中身は空っぽでいいですが、Dispose()だけは定義を書いてあげないとエラーになる
// 仕様上最後にDisposeを呼ぶ必要があるのでこうしている
public partial void Dispose();
}
// 使用例
public class FixedEnemyManager : MonoBehaviour
{
private EnemyContainer enemies = new(1000);
// 敵を追加(シンプル!)
public void AddEnemy(GameObject enemy)
{
enemies.Add(enemy,
new EnemyHealth { hp = 100 },
new EnemyMovement { speed = 5f },
enemy.GetComponent<EnemyAI>()
);
}
// 敵を削除
public void RemoveEnemy(GameObject enemy)
{
enemies.Remove(enemy);
Destroy(enemy);
}
// 全敵の更新
unsafe void Update()
{
UnsafeList<EnemyHealth>.ReadOnly healthList = enemies.GetEnemyHealthReadOnly();
Span<EnemyAI> enemyAi = enemies.GetEnemyAIsSpan();
// 連続メモリアクセスで高速でHp更新
for ( int i = 0; i < healthList.Length; i++ )
{
enemyAi[i].hpRate = healthList.Ptr[i].hp / healthList.Ptr[i].maxHp;
}
}
}
この FixedEnemyManager
の例で作成されたコンテナは、アトリビュートで指定されたデータ型専用のデータ管理ツールです。
二つのパラメータの内一つ目がアンマネージ型のType型配列で、二つ目はマネージ型のTye型配列になります。
つまり生成クラスの中にはこんな感じのフィールドがあるということです。
生成コードの一部(展開)
#region 管理対象のデータ
/// <summary>EnemyHealthデータのUnsafeList</summary>
private UnsafeList<EnemyHealth> _enemyHealth;
/// <summary>EnemyMovementデータのUnsafeList</summary>
private UnsafeList<EnemyMovement> _enemyMovement;
/// <summary>EnemyAIデータの配列</summary>
private EnemyAI[] _enemyAIs;
#endregion
見ての通りアンマネージ型はUnsafeListで管理し、マネージ型は配列で管理しています。
これらのデータにハッシュテーブルをかぶせることでDictionaryライクな使い方ができるようにしている、というのがこのコンテナの中身です。
一応生成コードの全体も貼っておきますね。
生成コード全体(展開)
// <auto-generated/>
#nullable enable
#pragma warning disable CS8600, CS8601, CS8602, CS8603, CS8604
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using UnityEngine;
namespace ZabutonTool
{
/// <summary>
/// 高性能なオブジェクトデータコンテナクラス
/// GameObjectをキーとして複数のデータ型を効率的に管理します
/// </summary>
public unsafe partial class EnemyContainer : IDisposable
{
#region 型定義
/// <summary>
/// ハッシュテーブルのエントリを表す構造体
/// </summary>
private struct Entry
{
/// <summary>ハッシュコード</summary>
public int HashCode;
/// <summary>値のインデックス</summary>
public int ValueIndex;
/// <summary>次のエントリのインデックス(衝突時のチェーン)</summary>
public int NextInBucket;
}
/// <summary>
/// メモリレイアウト情報を管理する構造体
/// </summary>
private struct MemoryLayout
{
/// <summary>EnemyHealthのメモリオフセット</summary>
public int EnemyHealthOffset;
/// <summary>EnemyMovementのメモリオフセット</summary>
public int EnemyMovementOffset;
/// <summary>総メモリサイズ</summary>
public int TotalSize;
}
#endregion
#region 定数フィールド
/// <summary>デフォルトの最大容量</summary>
private const int DEFAULT_MAX_CAPACITY = 130;
#endregion
#region フィールド
/// <summary>
/// バケット配列(各要素はエントリへのインデックス、-1は空)
/// </summary>
private int[] _buckets;
/// <summary>
/// エントリのリスト(ハッシュ→インデックスのマッピング)
/// </summary>
private UnsafeList<Entry> _entries;
/// <summary>
/// エントリ削除後の使いまわせるスペースのインデックス
/// </summary>
private Stack<int> _freeEntry;
/// <summary>
/// 一括確保したメモリブロック
/// </summary>
private byte* _bulkMemory;
/// <summary>
/// 総メモリサイズ
/// </summary>
private int _totalMemorySize;
/// <summary>
/// 現在の要素数
/// </summary>
private int _count;
/// <summary>
/// 最大容量
/// </summary>
private int _maxCapacity;
/// <summary>
/// メモリアロケータ
/// </summary>
private Allocator _allocator;
/// <summary>
/// 解放済みフラグ
/// </summary>
private bool _isDisposed;
#region 管理対象のデータ
/// <summary>EnemyHealthデータのUnsafeList</summary>
private UnsafeList<ZabutonTool.EnemyHealth> _enemyHealth;
/// <summary>EnemyMovementデータのUnsafeList</summary>
private UnsafeList<ZabutonTool.EnemyMovement> _enemyMovement;
/// <summary>EnemyAIデータの配列</summary>
private ZabutonTool.EnemyAI[] _enemyAIs;
#endregion
#endregion
#region プロパティ(生成)
/// <summary>
/// 現在の要素数
/// </summary>
public int Count => this._count;
/// <summary>
/// 最大容量
/// </summary>
public int MaxCapacity => this._maxCapacity;
/// <summary>
/// 使用率(0.0~1.0)
/// </summary>
public float UsageRatio => (float)this._count / this._maxCapacity;
/// <summary>EnemyAIデータのSpanを取得します</summary>
public Span<ZabutonTool.EnemyAI> GetEnemyAIs => this._enemyAIs.AsSpan().Slice(0, this._count);
#endregion
#region コンストラクタ
/// <summary>
/// コンストラクタ
/// </summary>
/// <param name="maxCapacity">最大容量(デフォルト: 130)</param>
/// <param name="allocator">メモリアロケータ(デフォルト: Persistent)</param>
public EnemyContainer(int maxCapacity = DEFAULT_MAX_CAPACITY, Allocator allocator = Allocator.Persistent)
{
this.InitializeContainer(maxCapacity, allocator);
}
#endregion
#region 初期化
/// <summary>
/// コンテナの初期化処理
/// </summary>
/// <param name="maxCapacity">最大容量</param>
/// <param name="allocator">メモリアロケータ</param>
private void InitializeContainer(int maxCapacity, Allocator allocator)
{
// 一万以上のサイズにはエラーを出す。
// なんでもスタックに置けばいいというわけでもないため制限。
if ( maxCapacity > 10000 )
{
throw new ArgumentOutOfRangeException("生成可能なコンテナのサイズは最大10000です。Capacityを範囲内に設定してください。");
}
this._maxCapacity = maxCapacity;
this._allocator = allocator;
this._count = 0;
this._isDisposed = false;
// バケット配列の初期化
this._buckets = new int[MakePrimeSizeBucket(maxCapacity)];
this._buckets.AsSpan().Fill(-1);
// エントリリストの初期化
this._entries = new UnsafeList<Entry>(this._buckets.Length * 2, allocator);
// 削除エントリ保管用のスタック作成
this._freeEntry = new Stack<int>(maxCapacity);
// メモリレイアウト計算とメモリ確保
MemoryLayout layout = this.CalculateMemoryLayout(maxCapacity);
this._totalMemorySize = layout.TotalSize;
this._bulkMemory = (byte*)UnsafeUtility.Malloc(this._totalMemorySize, 64, allocator);
UnsafeUtility.MemClear(this._bulkMemory, this._totalMemorySize);
// データ構造の初期化
this._enemyHealth = new UnsafeList<ZabutonTool.EnemyHealth>(
(ZabutonTool.EnemyHealth*)(this._bulkMemory + layout.EnemyHealthOffset),
maxCapacity);
this._enemyHealth.Length = 0;
this._enemyMovement = new UnsafeList<ZabutonTool.EnemyMovement>(
(ZabutonTool.EnemyMovement*)(this._bulkMemory + layout.EnemyMovementOffset),
maxCapacity);
this._enemyMovement.Length = 0;
this._enemyAIs = new ZabutonTool.EnemyAI[maxCapacity];
}
#endregion
#region 追加・更新
/// <summary>
/// GameObjectとデータを追加します
/// </summary>
/// <param name="obj">キーとなるGameObject</param>
/// <returns>追加されたデータのインデックス</returns>
/// <exception cref="ArgumentNullException">objがnullの場合</exception>
/// <exception cref="InvalidOperationException">最大容量を超えた場合</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int Add(GameObject obj, ZabutonTool.EnemyHealth enemyHealth, ZabutonTool.EnemyMovement enemyMovement, ZabutonTool.EnemyAI enemyAI)
{
if (obj == null) throw new ArgumentNullException(nameof(obj));
return this.AddByHash(obj.GetHashCode(), enemyHealth, enemyMovement, enemyAI);
}
/// <summary>
/// ハッシュコードでデータを追加します
/// </summary>
/// <param name="hashCode">ハッシュコード</param>
/// <returns>追加されたデータのインデックス</returns>
/// <exception cref="InvalidOperationException">最大容量を超えた場合</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int AddByHash(int hashCode, ZabutonTool.EnemyHealth enemyHealth, ZabutonTool.EnemyMovement enemyMovement, ZabutonTool.EnemyAI enemyAI)
{
if (this.TryGetIndexByHash(hashCode, out int existingIndex))
{
this._enemyHealth[existingIndex] = enemyHealth;
this._enemyMovement[existingIndex] = enemyMovement;
this._enemyAIs[existingIndex] = enemyAI;
return existingIndex;
}
if (this._count >= this._maxCapacity)
throw new InvalidOperationException($"Maximum capacity ({this._maxCapacity}) exceeded");
int dataIndex = this._count;
this._enemyHealth.AddNoResize(enemyHealth);
this._enemyMovement.AddNoResize(enemyMovement);
this._enemyAIs[dataIndex] = enemyAI;
this.RegisterToHashTable(hashCode, dataIndex);
this._count++;
return dataIndex;
}
#endregion
#region 削除(スワップ削除)
/// <summary>
/// GameObjectを削除します(スワップ削除)
/// </summary>
/// <param name="obj">削除するGameObject</param>
/// <returns>削除に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Remove(GameObject obj)
{
if (obj == null) return false;
return this.RemoveByHash(obj.GetHashCode());
}
/// <summary>
/// ハッシュコードで削除します(スワップ削除)
/// </summary>
/// <param name="hashCode">削除するハッシュコード</param>
/// <returns>削除に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool RemoveByHash(int hashCode)
{
if (!this.TryGetIndexByHash(hashCode, out int dataIndex)) return false;
int lastIndex = this._count - 1;
if (dataIndex != lastIndex)
{
this._enemyHealth.RemoveAtSwapBack(dataIndex);
this._enemyMovement.RemoveAtSwapBack(dataIndex);
this._enemyAIs[dataIndex] = this._enemyAIs[lastIndex];
}
else
{
this._enemyHealth.Length--;
this._enemyMovement.Length--;
}
this._enemyAIs[lastIndex] = null;
this.RemoveFromHashTable(hashCode);
this._count--;
return true;
}
#endregion
#region データ取得
/// <summary>
/// GameObjectからすべてのデータを取得します
/// </summary>
/// <param name="obj">キーとなるGameObject</param>
/// <returns>取得に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetValue(GameObject obj, out ZabutonTool.EnemyHealth enemyHealth, out ZabutonTool.EnemyMovement enemyMovement, out ZabutonTool.EnemyAI enemyAI, out int index)
{
if (obj == null)
{
enemyHealth = default;
enemyMovement = default;
enemyAI = null;
index = -1;
return false;
}
return this.TryGetValueByHash(obj.GetHashCode(), out enemyHealth, out enemyMovement, out enemyAI,out index);
}
/// <summary>
/// ハッシュコードからすべてのデータを取得します
/// </summary>
/// <param name="hashCode">ハッシュコード</param>
/// <returns>取得に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetValueByHash(int hashCode, out ZabutonTool.EnemyHealth enemyHealth, out ZabutonTool.EnemyMovement enemyMovement, out ZabutonTool.EnemyAI enemyAI, out int index)
{
if (this.TryGetIndexByHash(hashCode, out int dataIndex))
{
enemyHealth = this._enemyHealth[dataIndex];
enemyMovement = this._enemyMovement[dataIndex];
enemyAI = this._enemyAIs[dataIndex];
index = dataIndex;
return true;
}
enemyHealth = default;
enemyMovement = default;
enemyAI = null;
index = -1;
return false;
}
/// <summary>
/// インデックスからすべてのデータを取得します
/// </summary>
/// <param name="index">データのインデックス</param>
/// <returns>取得に成功した場合true</returns>
public bool TryGetByIndex(int index, out ZabutonTool.EnemyHealth enemyHealth, out ZabutonTool.EnemyMovement enemyMovement, out ZabutonTool.EnemyAI enemyAI)
{
if (index < 0 || index >= this._count)
{
enemyHealth = default;
enemyMovement = default;
enemyAI = null;
return false;
}
enemyHealth = this._enemyHealth[index];
enemyMovement = this._enemyMovement[index];
enemyAI = this._enemyAIs[index];
return true;
}
/// <summary>
/// ハッシュ値から値のインデックスを取得します
/// </summary>
/// <param name="hashCode">ハッシュコード</param>
/// <param name="index">取得されたインデックス</param>
/// <returns>取得に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Unity.Burst.BurstCompile]
public bool TryGetIndexByHash(int hashCode, out int index)
{
int bucketIndex = this.GetBucketIndex(hashCode);
int entryIndex = this._buckets[bucketIndex];
while ( entryIndex != -1 )
{
ref Entry entry = ref this._entries.ElementAt(entryIndex);
if ( entry.HashCode == hashCode )
{
index = entry.ValueIndex;
return true;
}
entryIndex = entry.NextInBucket;
}
index = -1;
return false;
}
#endregion
#region 構造体:EnemyHealthのデータ取得
/// <summary>
/// インデックスからEnemyHealthデータを直接取得します
/// </summary>
/// <param name="index">データのインデックス</param>
/// <returns>EnemyHealthデータの参照</returns>
/// <exception cref="ArgumentOutOfRangeException">インデックスが範囲外の場合</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ref ZabutonTool.EnemyHealth GetEnemyHealthByIndex(int index)
{
if (index < 0 || index >= this._count)
throw new ArgumentOutOfRangeException(nameof(index));
return ref this._enemyHealth.ElementAt(index);
}
/// <summary>
/// インデックスからEnemyHealthデータを安全に取得します
/// </summary>
/// <param name="index">データのインデックス</param>
/// <param name="value">EnemyHealthデータ</param>
/// <returns>取得に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetEnemyHealthByIndex(int index, out ZabutonTool.EnemyHealth value)
{
if (index < 0 || index >= this._count)
{
value = default;
return false;
}
value = this._enemyHealth[index];
return true;
}
/// <summary>
/// GameObjectからEnemyHealthデータを取得します
/// </summary>
/// <param name="obj">キーとなるGameObject</param>
/// <param name="value">EnemyHealthデータ</param>
/// <returns>取得に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetEnemyHealthByGameObject(GameObject obj, out ZabutonTool.EnemyHealth value)
{
if (obj == null)
{
value = default;
return false;
}
return this.TryGetEnemyHealthByHash(obj.GetHashCode(), out value);
}
/// <summary>
/// ハッシュコードからEnemyHealthデータを取得します
/// </summary>
/// <param name="hashCode">ハッシュコード</param>
/// <param name="value">EnemyHealthデータ</param>
/// <returns>取得に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetEnemyHealthByHash(int hashCode, out ZabutonTool.EnemyHealth value)
{
if (this.TryGetIndexByHash(hashCode, out int dataIndex))
{
value = this._enemyHealth[dataIndex];
return true;
}
value = default;
return false;
}
/// <summary>
/// EnemyHealthデータの読み取り専用ビューを取得します(JobSystem用)
/// </summary>
/// <returns>EnemyHealthデータのUnsafeList<T>.ReadOnly()</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public UnsafeList<ZabutonTool.EnemyHealth>.ReadOnly GetEnemyHealthReadOnly()
{
return this._enemyHealth.AsReadOnly();
}
#endregion
#region 構造体:EnemyMovementのデータ取得
/// <summary>
/// インデックスからEnemyMovementデータを直接取得します
/// </summary>
/// <param name="index">データのインデックス</param>
/// <returns>EnemyMovementデータの参照</returns>
/// <exception cref="ArgumentOutOfRangeException">インデックスが範囲外の場合</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ref ZabutonTool.EnemyMovement GetEnemyMovementByIndex(int index)
{
if (index < 0 || index >= this._count)
throw new ArgumentOutOfRangeException(nameof(index));
return ref this._enemyMovement.ElementAt(index);
}
/// <summary>
/// インデックスからEnemyMovementデータを安全に取得します
/// </summary>
/// <param name="index">データのインデックス</param>
/// <param name="value">EnemyMovementデータ</param>
/// <returns>取得に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetEnemyMovementByIndex(int index, out ZabutonTool.EnemyMovement value)
{
if (index < 0 || index >= this._count)
{
value = default;
return false;
}
value = this._enemyMovement[index];
return true;
}
/// <summary>
/// GameObjectからEnemyMovementデータを取得します
/// </summary>
/// <param name="obj">キーとなるGameObject</param>
/// <param name="value">EnemyMovementデータ</param>
/// <returns>取得に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetEnemyMovementByGameObject(GameObject obj, out ZabutonTool.EnemyMovement value)
{
if (obj == null)
{
value = default;
return false;
}
return this.TryGetEnemyMovementByHash(obj.GetHashCode(), out value);
}
/// <summary>
/// ハッシュコードからEnemyMovementデータを取得します
/// </summary>
/// <param name="hashCode">ハッシュコード</param>
/// <param name="value">EnemyMovementデータ</param>
/// <returns>取得に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetEnemyMovementByHash(int hashCode, out ZabutonTool.EnemyMovement value)
{
if (this.TryGetIndexByHash(hashCode, out int dataIndex))
{
value = this._enemyMovement[dataIndex];
return true;
}
value = default;
return false;
}
/// <summary>
/// EnemyMovementデータの読み取り専用ビューを取得します(JobSystem用)
/// </summary>
/// <returns>EnemyMovementデータのUnsafeList<T>.ReadOnly()</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public UnsafeList<ZabutonTool.EnemyMovement>.ReadOnly GetEnemyMovementReadOnly()
{
return this._enemyMovement.AsReadOnly();
}
#endregion
#region クラス:EnemyAIのデータ取得
/// <summary>
/// インデックスからEnemyAIデータを直接取得します
/// </summary>
/// <param name="index">データのインデックス</param>
/// <returns>EnemyAIデータ</returns>
/// <exception cref="ArgumentOutOfRangeException">インデックスが範囲外の場合</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ZabutonTool.EnemyAI GetEnemyAIByIndex(int index)
{
if (index < 0 || index >= this._count)
throw new ArgumentOutOfRangeException(nameof(index));
return this._enemyAIs[index];
}
/// <summary>
/// インデックスからEnemyAIデータを安全に取得します
/// </summary>
/// <param name="index">データのインデックス</param>
/// <param name="value">EnemyAIデータ</param>
/// <returns>取得に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetEnemyAIByIndex(int index, out ZabutonTool.EnemyAI value)
{
if (index < 0 || index >= this._count)
{
value = null;
return false;
}
value = this._enemyAIs[index];
return true;
}
/// <summary>
/// GameObjectからEnemyAIデータを取得します
/// </summary>
/// <param name="obj">キーとなるGameObject</param>
/// <param name="value">EnemyAIデータ</param>
/// <returns>取得に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetEnemyAIByGameObject(GameObject obj, out ZabutonTool.EnemyAI value)
{
if (obj == null)
{
value = null;
return false;
}
return this.TryGetEnemyAIByHash(obj.GetHashCode(), out value);
}
/// <summary>
/// ハッシュコードからEnemyAIデータを取得します
/// </summary>
/// <param name="hashCode">ハッシュコード</param>
/// <param name="value">EnemyAIデータ</param>
/// <returns>取得に成功した場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetEnemyAIByHash(int hashCode, out ZabutonTool.EnemyAI value)
{
if (this.TryGetIndexByHash(hashCode, out int dataIndex))
{
value = this._enemyAIs[dataIndex];
return true;
}
value = null;
return false;
}
/// <summary>
/// EnemyAIデータのSpanを取得します(配列ベース)
/// </summary>
/// <returns>EnemyAIデータのSpan</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Span<ZabutonTool.EnemyAI> GetEnemyAIsSpan()
{
return this._enemyAIs.AsSpan().Slice(0, this._count);
}
#endregion
#region まとめたビュー取得メソッド
/// <summary>
/// すべてのstruct型データの読み取り専用ビューをタプルで取得します(JobSystem用)
/// </summary>
/// <returns>すべてのstruct型データのデータのUnsafeList<T>.ReadOnly()のタプル</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public (UnsafeList<ZabutonTool.EnemyHealth>.ReadOnly enemyHealthReader, UnsafeList<ZabutonTool.EnemyMovement>.ReadOnly enemyMovementReader) GetAllStructReadOnly()
{
return (this._enemyHealth.AsReadOnly(), this._enemyMovement.AsReadOnly());
}
#endregion
#region ユーティリティ(生成)
/// <summary>
/// すべてのデータをクリアします
/// </summary>
public void Clear()
{
// バケットをリセット
this._buckets.AsSpan().Fill(-1);
// エントリとマッピングをクリア
this._entries.Clear();
// データをクリア
this._enemyHealth.Length = 0;
this._enemyMovement.Length = 0;
Array.Clear(this._enemyAIs, 0, this._count);
this._count = 0;
}
/// <summary>
/// 指定したGameObjectが存在するか確認します
/// </summary>
/// <param name="obj">確認するGameObject</param>
/// <returns>存在する場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool ContainsKey(GameObject obj)
{
if ( obj == null )
{
return false;
}
return this.ContainsKeyByHash(obj.GetHashCode());
}
/// <summary>
/// 指定したハッシュコードが存在するか確認します
/// </summary>
/// <param name="hashCode">確認するハッシュコード</param>
/// <returns>存在する場合true</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool ContainsKeyByHash(int hashCode)
{
return this.TryGetIndexByHash(hashCode, out int _);
}
#endregion
#region 内部メソッド
/// <summary>
/// メモリレイアウトを計算します
/// </summary>
/// <param name="capacity">容量</param>
/// <returns>メモリレイアウト情報</returns>
private MemoryLayout CalculateMemoryLayout(int capacity)
{
MemoryLayout layout = new();
int currentOffset = 0;
layout.EnemyHealthOffset = currentOffset;
currentOffset += capacity * sizeof(ZabutonTool.EnemyHealth);
currentOffset = AlignTo(currentOffset, 64);
layout.EnemyMovementOffset = currentOffset;
currentOffset += capacity * sizeof(ZabutonTool.EnemyMovement);
currentOffset = AlignTo(currentOffset, 64);
layout.TotalSize = currentOffset;
return layout;
}
/// <summary>
/// メモリオフセットをアライメントに合わせて調整します
/// </summary>
/// <param name="memoryOffset">メモリオフセット</param>
/// <param name="alignment">アライメント</param>
/// <returns>調整されたオフセット</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int AlignTo(int memoryOffset, int alignment)
{
return (memoryOffset + alignment - 1) & ~(alignment - 1);
}
/// <summary>
/// バケットのサイズを計算する処理。
/// サイズは一万まで対応。
/// </summary>
/// <param name="setCapacity"></param>
/// <returns></returns>
int MakePrimeSizeBucket(int setCapacity)
{
// 一万五千までの素数(中身は省略)
Span<int> prime = stackalloc int[] { 2, ,,,, 15499 };
int requireSize = (int)(setCapacity * 1.5);
int pIndex = prime.BinarySearch(requireSize);
pIndex = pIndex < 0 ? ~pIndex : pIndex;
// 必要以上を満たす素数のサイズを作成。
return prime[pIndex];
}
/// <summary>
/// バケットインデックスを計算します
/// </summary>
/// <param name="hashCode">ハッシュコード</param>
/// <returns>バケットインデックス</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private int GetBucketIndex(int hashCode)
{
return (hashCode & 0x7FFFFFFF) % this._buckets.Length;
}
/// <summary>
/// ハッシュテーブルにエントリを登録します
/// </summary>
/// <param name="hashCode">ハッシュコード</param>
/// <param name="dataIndex">データインデックス</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void RegisterToHashTable(int hashCode, int dataIndex)
{
int bucketIndex = this.GetBucketIndex(hashCode);
Entry newEntry = new()
{
HashCode = hashCode,
ValueIndex = dataIndex,
NextInBucket = this._buckets[bucketIndex]
};
int newEntryIndex;
if ( this._freeEntry.TryPop(out newEntryIndex) )
{
this._entries[newEntryIndex] = newEntry;
}
else
{
newEntryIndex = this._entries.Length;
this._entries.AddNoResize(newEntry);
}
this._buckets[bucketIndex] = newEntryIndex;
}
/// <summary>
/// エントリのデータインデックスを更新します
/// </summary>
/// <param name="hashCode">ハッシュコード</param>
/// <param name="newDataIndex">新しいデータインデックス</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Unity.Burst.BurstCompile]
private void UpdateEntryDataIndex(int hashCode, int newDataIndex)
{
int bucketIndex = this.GetBucketIndex(hashCode);
int entryIndex = this._buckets[bucketIndex];
while ( entryIndex != -1 )
{
ref Entry entry = ref this._entries.ElementAt(entryIndex);
if ( entry.HashCode == hashCode )
{
entry.ValueIndex = newDataIndex;
return;
}
entryIndex = entry.NextInBucket;
}
}
/// <summary>
/// ハッシュテーブルからエントリを削除します
/// </summary>
/// <param name="hashCode">削除するハッシュコード</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Unity.Burst.BurstCompile]
private void RemoveFromHashTable(int hashCode)
{
int bucketIndex = this.GetBucketIndex(hashCode);
int entryIndex = this._buckets[bucketIndex];
int prevIndex = -1;
while ( entryIndex != -1 )
{
ref Entry entry = ref this._entries.ElementAt(entryIndex);
if ( entry.HashCode == hashCode )
{
if ( prevIndex == -1 )
{
this._buckets[bucketIndex] = entry.NextInBucket;
}
else
{
ref Entry prevEntry = ref this._entries.ElementAt(prevIndex);
prevEntry.NextInBucket = entry.NextInBucket;
}
this._freeEntry.Push(entryIndex);
return;
}
prevIndex = entryIndex;
entryIndex = entry.NextInBucket;
}
}
#endregion
#region リソース解放
/// <summary>
/// リソースを解放します
/// </summary>
public partial void Dispose()
{
if ( this._isDisposed )
{
return;
}
if ( this._bulkMemory != null )
{
UnsafeUtility.Free(this._bulkMemory, this._allocator);
this._bulkMemory = null;
}
this._entries.Dispose();
this._isDisposed = true;
}
#endregion
}
}
従来のコードとのパフォーマンス比較
先ほどの従来のコードとツール使用コードをベースにテストコードを組んでベンチマークを行いました。
その結果が以下です。
操作 | 従来手法 | ツール利用 | 改善率 |
---|---|---|---|
追加 (Add) | 0.0990ms | 0.0477ms | 2.08倍高速 |
削除 (Remove) | 0.2566ms | 0.0035ms | 73.3倍高速 |
ランダムアクセス | 0.5602ms | 0.2300ms | 2.44倍高速 |
連続更新 (Update) | 1.9784ms | 0.6746ms | 2.93倍高速 |
操作 | 従来手法 | ツール利用 | 削減率 |
---|---|---|---|
追加 (1000件) | 81 | 0 | 100%削減 |
メモリ確保 (100件) | 195 | 65 | 66.7%削減 |
なぜ速いのか?
1. BurstCompile
アトリビュートを使用
次の例の通り、生成コードには要所で BurstCompile
アトリビュートが使用されています。
このたった一行をくっつけるだけでかなり速度向上するのがUnityの魔法です。
生成コードの一部(展開)
/// <summary>
/// エントリのデータインデックスを更新します
/// </summary>
/// <param name="hashCode">ハッシュコード</param>
/// <param name="newDataIndex">新しいデータインデックス</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Unity.Burst.BurstCompile]
private void UpdateEntryDataIndex(int hashCode, int newDataIndex)
{
int bucketIndex = this.GetBucketIndex(hashCode);
int entryIndex = this._buckets[bucketIndex];
while ( entryIndex != -1 )
{
ref Entry entry = ref this._entries.ElementAt(entryIndex);
if ( entry.HashCode == hashCode )
{
entry.ValueIndex = newDataIndex;
return;
}
entryIndex = entry.NextInBucket;
}
}
2. 64バイトアライメントされた最適メモリ配置
以下の例の通り、CPUキャッシュラインにデータが整列しています。
そして同一オブジェクトに属するデータを一か所に集めることでキャッシュヒット率向上が期待できます。
生成コードのメモリレイアウト計算(展開)
/// <summary>
/// メモリレイアウトを計算します
/// </summary>
/// <param name="capacity">容量</param>
/// <returns>メモリレイアウト情報</returns>
private MemoryLayout CalculateMemoryLayout(int capacity)
{
MemoryLayout layout = new();
int currentOffset = 0;
layout.EnemyHealthOffset = currentOffset;
currentOffset += capacity * sizeof(ToolCodeGenerator.Tests.DataContainerPerformanceTests.EnemyHealth);
currentOffset = AlignTo(currentOffset, 64);
layout.EnemyMovementOffset = currentOffset;
currentOffset += capacity * sizeof(ToolCodeGenerator.Tests.DataContainerPerformanceTests.EnemyMovement);
currentOffset = AlignTo(currentOffset, 64);
layout.TotalSize = currentOffset;
return layout;
}
3. ゼロアロケーションを目指した設計
マネージ型も管理するため完全にゼロアロケーションとはいきませんが、意識した設計にしています。
そのために先ほどのメモリ計算処理などを使いつつ、初期化時にすべてのメモリを確保しています。
そして要素の追加、削除にも UnsafeList.AddNoResize()
や UnsafeList.RemoveAtSwapBack()
を利用してGCを予防しています。
4. UnityのGameObjectの管理に特化したハッシュテーブル
このツールのハッシュテーブルは、GameObjectの GetHashCode()
関数の仕様を利用して効率化しています。
具体的に言うと、キー重複が起こらないことが保証されているのを前提に高速化しました。
UnityのGameObject型の GetHashCode()
は、内部的には GetInstanceID()
(ゲームオブジェクトごとに識別用の一意のIDを返すメソッド)と同じ値を返しています。
つまり、キーをこのハッシュ値に限定する限りキーは一意で、重複を考慮しない実装にできるということです。
だからこそ複雑なハッシュ関数は必要ないですし、超シンプルな実装でハッシュテーブルを作れました。
その他のメリット
JobSystem利用可能
このパッケージで生成されたアンマネージ型のフィールドはそのままJobSystemに投入可能です。
これはかなり便利です。
Unityで高速化と言ったらJobSystemですが、そのためのデータ構造の最適化が本当に面倒でした。
しかしこのコンテナを使えば、そんな苦労もなくちょちょいとJobSystemを導入できてしまいます!
Jobコード(展開)
// ===== Jobの定義 =====
[BurstCompile]
struct UpdateHealthJob : IJobParallelFor
{
[ReadOnly] public UnsafeList<EnemyHealth>.ReadOnly healths;
public NativeArray<float> damages;
public void Execute(int index)
{
// 読み取り専用アクセス
var health = healths[index];
damages[index] = CalculateDamage(health.HP);
}
}
// ===== UnsafeList.ReadOnlyビューを取得 =====
var healthReadOnly = enemies.GetEnemyHealthReadOnly();
var movementReadOnly = enemies.GetEnemyMovementReadOnly();
// ===== Jobの実行 =====
var job = new UpdateHealthJob
{
healths = healthReadOnly,
damages = new NativeArray<float>(enemies.Count, Allocator.TempJob)
};
job.Schedule(enemies.Count, 64).Complete();
partialクラスによる高度な拡張
生成されるコンテナクラスはpartial
なので、ユーザー側で自由に機能を拡張できます。
デバッグ用関数を作ってみたり、内部のUnsafeListを直接JobSystemに渡してもいいでしょう。
UnsafeListへのアクセスはデフォルトで禁止していますが、ユーザー側で拡張し、アクセス可能にすればJobSystemがさらに便利で高速になります。
拡張コード例(展開)
[ContainerSetting(
new[] { typeof(EnemyHealth), typeof(EnemyMovement) },
new[] { typeof(EnemyAI) }
)]
public unsafe partial class EnemyContainer
{
// 必須のDispose実装
public partial void Dispose();
// ===== デバッグ機能 =====
/// <summary>
/// デバッグ情報を取得
/// </summary>
public string GetDebugString()
{
return $"容量: {MaxCapacity}, 使用中: {Count}, 使用率: {UsageRatio:P1}";
}
/// <summary>
/// メモリ使用量詳細
/// </summary>
public string GetMemoryInfo()
{
var sb = new StringBuilder();
sb.AppendLine($"総メモリサイズ: {_totalMemorySize} bytes");
sb.AppendLine($"EnemyHealth配列: {Count * sizeof(EnemyHealth)} bytes");
sb.AppendLine($"EnemyMovement配列: {Count * sizeof(EnemyMovement)} bytes");
return sb.ToString();
}
// ===== その他拡張 =====
/// <summary>
/// UnsafeListへの直接アクセス(キケンですが早い!)
/// </summary>
public UnsafeList<EnemyHealth> GetHealthListDirect()
{
return _enemyHealth; // 内部フィールドに直接アクセス
}
/// <summary>
/// 特定条件の敵を検索
/// </summary>
/// <param name="threshold"></param>
/// <returns></returns>
public List<int> FindLowHealthEnemies(float threshold)
{
var result = new List<int>();
for ( int i = 0; i < Count; i++ )
{
ref var health = ref GetEnemyHealthByIndex(i);
if ( health.hp < threshold )
{
result.Add(i);
}
}
return result;
}
}
インポート方法
一通り話し終えたのでインポート方法をお伝えします。
このツールのインポートは簡単です。
まず以下のリポジトリにアクセスし、リリースからパッケージをインストールしてください。
赤枠がパッケージ |
---|
![]() |
そしてダウンロードしたらインポートしたいUnityプロジェクトを開いた状態でパッケージをクリックしてください。
すると以下の画像のようにインポート画面が出てきて、成功するとAsset直下にdllが入ったフォルダが作成されます。
インポートの流れ |
---|
![]() |
![]() |
![]() |
このdllは片方がアトリビュートが入ったライブラリのdllで、もう片方がコード生成に使うアナライザのdllです。
ちなみに特段設定はいりませんので、インポート後の状態でOKです。
必要に応じてアトリビュートのdllの方には参照をつけてあげてください。
終わりに
以上で自作ツールの紹介を終わりますが、もし使う人がいた時のために二つだけ注意喚起をさせてください。
注意点1:クラス内で定義したクラスにはこのツールは使えません!
これは例えば以下のような場合を指します。
無効コード例(展開)
/// <summary>
/// 入れ子になったクラスに対しては正しくコード生成が行えません。
/// </summary>
public class HogeClass
{
[ContainerSetting(
structType: new[] { typeof(EnemyHealth), typeof(EnemyMovement) },
classType: new[] { typeof(EnemyAI) })]
public partial class AntiPattern
{
}
}
名前空間はRoslynの解析情報から取得できるのですが、入れ子クラスに関しては力及ばずどうにもできませんでした。
入れ子クラスでコンテナを作成するのはやめた方がよさそうです。
注意点2:要素数は一万が上限です!!
これはコンテナを初期化する時、コンストラクタに渡すCapacity(最大要素数)の上限が一万だということです。
それ以上にすると例外が飛んできます。
このような制限を設けた理由は、アンマネージ型をスタック上で管理する都合上、あまり多くの要素を置けるようにはしない方がいいと感じたからです。
一万でも少し多い気がしています。
どうでしょうか……?
stackallocを使っていた時、思ったより早く上限に引っかかった記憶があります。
このあたりについて知見が足りていないので、何かご意見あれば教えてください。
追記:
よく考えたらメモリ確保時に特定のサイズ超えてたら警告出す方がよさそうですね。
いずれ調査・検証して実装します。
ごあいさつ
ということで、今回のツールの紹介を終わります。
良ければどなたか使ってみてください。
もちろんツールに関するご意見やご指摘もお待ちしています!!