【UE5】ユニークIDを作る方法

プログラマーの尾関です。

今回は、ゲーム内のオブジェクトのインスタンスが一意であることを保証するユニークIDを作る方法について書いてみます。

方法1. FGuidを使う

FGuid はグローバルユニークIDを作成するクラスで、衝突しにくい文字列のIDを作る機能を提供します。

#include "Misc/Guid.h"

// GUIDを生成します.
FString GenerateUniqueId()
{
    FGuid NewGuid = FGuid::NewGuid();
    return NewGuid.ToString(); // 文字列形式で返す
}

上記の関数は例えば「5E633F4A4F761B78739E49B40012179E」といった文字列を生成します。

ただ問題点としては、「文字列」であるためメモリ効率やパフォーマンスという点では不利です。そのため、頻繁に生成や破棄されないオブジェクトのIDとして使う…、のであれば選択肢としては良さそうです。

方法2. UObjectBase::GetUniqueIDを使う

Unreal Engineの UObject は GetUniqueID() でオブジェクトが生存している場合、それがユニークであることを保証するIDが得られます。オブジェクトのライフサイクルとユニークIDの利用が一致していれば、この方法はお手軽に使えて便利そうです。

Information

GetUniqueIDは、オブジェクトが破棄された場合にこのIDが再利用されます。

そのため、対象のオブジェクトが頻繁に生成と破棄を繰り返すといった場合、不向きである可能性があります。

方法3. カスタムハンドルを作る

Unreal EngineでのユニークIDの作成方法をいくつか調べたところ、従来のゲームプログラムの定番である「カスタムハンドル」での実装が一番良さそうでした。

この方法は、例えばオブジェクトのトータルの生成回数(X)を 48bit、インスタンスを管理する配列の要素番号(Y)を 16bit で定義し、合計 64bit (uint64) をハンドルのデータ型として定義します。

そしてオブジェクトを生成するたびに 生成回数(X) の値をインクリメントすることで、IDの重複を防ぐ方法です。48bit あれば約281兆の幅があるので基本的には重複しません。

コードの実装例としては以下の便利関数を用意しておくと良いと思います。

// ■ユニークID生成.
// idは(0~281474976710655 [48bit]) indexは(0~65535 [16bit])
// |63------|15-----0|
// |生成回数|要素番号|
inline uint64 UNIQUE_MAKE_ID_U64( uint64 id, uint16 index ) { //!< ユニークIDを生成.
  return ( ((uint64)id << 0x10) | index );
}
inline uint64 UNIQUE_GET_ID_U64( uint64 uniqueId ) { //!< IDを取得.
  return ( uniqueId >> 0x10 );
}
inline uint16 UNIQUE_GET_INDEX( uint16 uniqueId ) { //!< INDEXを取得.
  return ( uniqueId & 0xffff );
}

ハンドルは uint64 でそのまま定義しても良いですが、型チェックできるように別途構造体を定義します。

/**
 * @brief タイマーイベントのハンドル.
 */
USTRUCT(BlueprintType)
struct FTimerEventHandle {
  GENERATED_BODY()

  uint64 Handle = 0;

  bool IsValid() const {
    // 0以外なら有効。ただしハンドルとして有効なだけでありオブジェクトの有効判定は別途必要.
    return Handle != 0;
  }
  void Clear() {
    // ハンドルをクリア.
    Handle = 0;
  }
  // 一致するかどうか.
  bool operator==(const FTimerEventHandle& Other) const {
    return Handle == Other.Handle; // ハンドルが一致するかどうかで判定.
  }
};

以下、ハンドルを生成する実装例です。まずはヘッダの定義。

/**
 * @brief 時間管理.
 */
UCLASS()
class SV_API ACPP_GameTimer : public AActor
{
  GENERATED_BODY()
public:
  // ------------------------------------------------------- const(s).
  static constexpr int32 MAX_TIMER_EVENT = 256; // 最大タイマーイベント数.
  /**
   * @brief タイマーイベントの登録.
   * @return 生成に成功した場合はハンドルを返します.
   */
  const FTimerEventHandle& RegisterTimerEvent(const FTimerEvent& TimerEvent);

  /**
   * @brief タイマーイベントの削除.
   * @param TimerEventHandle 削除するタイマーイベントのハンドル.
   * @param isSort 削除成功後にソートを実行するかどうか.
   * @return 削除に成功した場合はtrueを返します.
   */
  bool RemoveTimerEvent(const FTimerEventHandle& TimerEventHandle, bool isSort=true);

  /**
   * @brief タイマーイベントの有効判定.
   * @param Handle 判定するタイマーイベントのハンドル.
   * @return 有効な場合はtrueを返します.
   */
  bool IsValidTimerEvent(const FTimerEventHandle& TimerEventHandle);

private: // -------------------------------------------------------- function(s)

  /**
   * @brief タイマーイベントの検索.
   * @return 見つからなかった場合は無効なイベントを返します.
   */
  FTimerEvent& _SearchEvent(FTimerEventHandle Handle);

private: // -------------------------------------------------------- var(s)
  /**
   * @brief タイマーイベントリスト (固定長配列).
   */
  UPROPERTY()
  TArray<FTimerEvent> m_TimerEvents;

private: // --------------------------------------------------------- static var(s)
  // タイマーイベントハンドル用の通し番号.
  static uint64 s_UniqueId;
};

生成回数は static変数で定義しても良いと思います。ただ1回のゲームサイクルが長い場合は static変数にしない方が良いかもしれません。

cpp側の定義例は以下の通り。(ハンドルの生成例)

// タイマーイベントハンドル用の通し番号.
uint64 ACPP_GameTimer::s_UniqueId = 1; // 1始まりにすることで初期値0と重複することを防止.
// 無効なハンドル.
static const FTimerEventHandle s_InvalidHandle = {};

/**
 * @brief タイマーイベントの登録.
 */
const FTimerEventHandle& ACPP_GameTimer::RegisterTimerEvent(const FTimerEvent& TimerEvent)
{
  // 要素に空きがあるかどうか.
  for(int index = 0; auto& LEvent : m_TimerEvents) {
    if(LEvent.Handle.IsValid()) {
      // 使用中.
      // ※C++20の残念仕様のためここでインクリメントが必要...
      index++;
      continue;
    }

    // 空きがあったので、ここに登録します.
    LEvent = TimerEvent;

    // 現在時間を登録.
    LEvent.StartTime = m_CurrentTime;

    // ユニークIDを生成します.
    LEvent.Handle.Handle = UNIQUE_MAKE_ID_U64(++s_UniqueId, index);

    // ソート実行.
    _SortTimerEvents();

    // ハンドルを返します.
    return LEvent.Handle;
  }

  // 見つからなかったら無効なハンドルを返します.
  return s_InvalidHandle;
}

すべてを記載するのは長いので、ハンドルの検索のコード例だけ記載します。

/**
  * @brief タイマーイベントの検索.
  * @return 見つからなかった場合は無効なイベントを返します.
  */
FTimerEvent& ACPP_GameTimer::_SearchEvent(FTimerEventHandle Handle)
{
  if(Handle.IsValid() == false) {
    // ハンドルが無効な場合は無効なイベントを返します.
    return s_InvalidEvent;
  }

  // ハンドルを検索します.
  int32 index = UNIQUE_GET_INDEX(Handle.Handle);
  if(index < 0 || index >= m_TimerEvents.Num()) {
    // 範囲外は無効.
    return s_InvalidEvent;
  }

  // ハンドルを取得します.
  auto& LEvent = m_TimerEvents[index];
  if(LEvent.Handle == Handle) {
    // ハンドルが一致したので、イベントを返します.
    return LEvent;
  }

  // ハンドルが無効な場合は無効なイベントを返します.
  return s_InvalidEvent;
}

この記事を書くきっかけとして、Unreal Engineが提供しているタイマーの仕組みを自作して細かく制御したいと思ったためとなります。そして Ureal Engine が提供しているタイマーハンドルのコード (FTimerHandle) の実装を見たところ、同様にカスタムハンドルで実装されていたので、ゲームで使うにはこの方法が最適なのかもしれません。

この方法の欠点としては、基本的に1つの種類のオブジェクトしか管理できません。もし複数の種類のオブジェクトを扱いたい場合には、生成回数の bit を削ってオブジェクト種別の bit を用意したり、別のハンドル型と管理を用意するのが良いかもしれません。

補足:TArrayは固定長配列として使用する

注意点として、ハンドルに要素数を持たせているため、要素番号のズレが起きないようにする仕組みが必要です。(RemoveAtなどは使わない)

例えば BeginPlay() などで、Reserve() で格納できる最大数を確保し、Add() であらかじめインスタンスを追加しておきます。

// 無効なイベント.
static FTimerEvent s_InvalidEvent = {};

// イベントの開始.
void ACPP_GameTimer::BeginPlay()
{
	Super::BeginPlay();

	// あらかじめ配列の要素を確保しておきます.
	m_TimerEvents.Reserve(MAX_TIMER_EVENT);
	for(int i = 0; i < MAX_TIMER_EVENT; i++) {
		m_TimerEvents.Add(s_InvalidEvent); // 無効なイベントを登録.
	}
}

その他の方法

今回は採用しませんでしたが、その他ユニークID生成に使えそうな方法があったので、メモ書きに近いですが情報を残しておきます。

  • CRC32チェックサム:これはどちらかというとデータの整合性をある程度保証するもので、32bitとコンパクトなためネットワークにおけるパケットの整合性のチェックに使われます。なのでゲーム内のオブジェクトに対して使うには不向きです
  • Naoidライブラリ: 確認はしていないですが、UUID/GUIDに近い印象ですが少ない文字列でそれなりのユニークIDを作成してくれるようです

以上、ユニークIDの生成方法についてでした。

\ 最新情報をチェック /