136.6K Views
June 05, 20
スライド概要
これは、社内勉強会で使われたスライドです。
UE4においてC++からUObjectを扱う際に必要な基礎知識をまとめました。
UObjectの扱いは従来のC++のメモリ管理とは異なる考え方が必要であるにも関わらず、学習の取っ掛かりとなる資料が見つけにくいという現状があります。
本資料は、UObjectの仕組みとよくある失敗例への対処法を学び、初学者がスムーズにプロジェクトに合流できるように手助けすることを目標としています。
株式会社ヘキサドライブの資料共有用アカウントです。 公式ブログ:https://hexadrive.jp/hexablog/ note :https://note.com/hexadrive
UObjectの動作原理 株式会社ヘキサドライブ 堀井 陽介
目次 UObjectとは? UObjectの意義 GCとライフサイクル バグ事例集 全体のまとめ おまけ
UObjectとは?
UObjectとは? https://docs.unrealengine.com/ja/Programming/UnrealArchitecture/Objects/index.html ◆ ゲーム オブジェクトを処理する堅牢なシステム ◆ 機能の一例 ガーベジ コレクション、参照の更新、リフレクション、シリアライズ、デフォルトのプロパティ変 更の自動更新、プロパティの自動初期化、エディタの自動統合、ランタイムに利用可能な型情 報、ネットワーク レプリケーション...
UObjectとは? ◆ 以下すべてUObject Uから始まるUTexture2D, UActorComponent ... Aから始まるAActor, ACharacter, AGameMode... コンテンツブラウザの全て ワールドアウトライナの全て Unreal C++で書くなら、 大体のクラスはUObject継承 独特のメモリ管理機構とGC new/deleteの置き換えとも言える
UObjectの意義 基本の使い方と意義
UObjectの意義 ◆ 少ないコードで安全にゲームリソースを管理 参照の有無に関わらず、任意タイミングで破棄できるので、コンテンツ都合で寿命をコントロー ルしたいゲーム制作において便利 解放忘れ、循環参照、ダングリングポインタ等のありがちなコーディング上のミスが起こりにく いよう設計されている
UObject以前について ◆ UObjectの有り難みを語るために、既存のシステムによるリソース管理を おさらい ● new/delete ● AddRef/Release ◆ ポイントは3つ ✓ 共有しやすさ ✓ バグの入りやすさ ✓ 寿命管理
new/delete ◆ new/deleteをゲームリソースの管理として考えた場合 ● 共有しにくい ● メモリ由来のバグを入れやすい ● SAFE_DELETE、などのマクロで頑張った
AddRef/Release ◆ COMオブジェクト、又は参照カウンタ型オブジェクトをゲームリソースの管 理につかう場合 ● 共有はしやすくなった スレッド間でも共有しやすい ● 依然、バグを作りやすかった AddRef忘れ、Release忘れ、循環参照 →どこが原因かぱっと見わからない ● もういらないことが明らかなのに他の場所で参照していると解放されない
UObjectを使ったリソース管理 ◆ UObjectを使ったリソース管理のメリット ● 共有が非常に楽 ● マークアンドスイープのGC(ガーベジコレクション)であり、 循環参照OK ● 他から参照されていても任意のタイミングで安全に破棄できる ● リソース管理の労力を極限まで省き、コンテンツの実装に集中できる
UObjectを使ったリソース管理 ◆ UObjectの難しさ ● ネット上に情報が少ない ● UPROPERTYという、C++に存在しない構文の学習コスト ● GCの負荷はかかってくる ● PendingKillという、C++的には有効なポインタだが、存在しないモノとして扱われる中間 状態を意識する必要
GCとライフサイクル
C++でのUObjectの保持のしかた ◆ ポインタの上にUPROPERTYを付ける事でGCがポインタを認識
UPROPERTYを付けたUObjectのポインタについて ◆ C++コンパイラの立場からは普通のポインタとしてコンパイルされる ◆ ただし、UBT(Unreal Build Tool)がUPROPERTYの構文を解析し、 GENERATED_BODYマクロで生成されるコードによりGCから認識可能になる
UObjectの生成 ◆ NewObjectで生成するが、用途ごとのラッパーを介して生成する場合も多い ◆ アクターはSpawnActorで生成 ◆ コンポーネントはCreateDefaultSubobjectで生成する事が多い ◆ エフェクトは SpawnEmitterAtLocation, SpawnEmitterAttachedで生成
UObjectの破棄について ◆ UObjectの破棄の過程は二通りあります a. 参照が無くなるとGCにより解放される(一般的なGCのイメージ) b. 参照が残っていても、任意のタイミングで破棄する機構がある(Pending Kill)
Pending Killについて ◆ 他から参照中でも任意のタイミングで破棄できる(MarkPendingKill) その実現のため、Pending Killという中間状態が存在 ◆ Pending KillのオブジェクトはいずれGCで解放される GCによって解放されると参照中だったポインタにnullptrが自動で代入される
MarkPendingKillについて ◆ UObject::MarkPendingKillを呼ぶと、そのUObjectは「存在しないモノ」として 扱われる ◆ UObject::MarkPendingKillは通常直接コールしない ● アクタは、BPからはDestroyActor、C++からはDestroyを介して呼ぶ ● アクタコンポーネントは、DestroyComponentを介して呼ぶ ◆ この「参照していても破棄できる」仕組みは、コンテンツの都合で寿命を管 理したいゲーム制作において便利で、UObjectの設計の根幹と言える
GCによるメモリの解放 ◆ UObjectは必ずGCを介して解放される プログラマが特定のUObjectを自分で解放するコードは書けない ◆ GCによりメモリが解放されると同時に、当該オブジェクトを参照していた 全てのUPROPERTYにnullptrが代入される ◆ これにより、UPROPERTYのUObjectポインタはダングリングポインタが発 生しない仕組み
参照維持の仕組みを理解する ◆ 参照維持について、実務上気にするのは2点 ● UObjectは生成しても参照を維持しないとGCによって突然解放されてしまう ● しかし、AActorとUActorComponentは意識して参照せずとも解放されることはない ◆ なぜそうなるのか ● RootSetと呼ばれるUObjectはGCされない ● RootSetが参照しているUObjectも同様にGCされない
RootSetから全てつながっている …. …. ◆ UWorldはRootSetとされていて、GCされない ◆ UWorldがULevelを参照、ULevelが以下全てを参照 ◆ これでAActorとその下のリソースがGCから守られる
UWorldがGCされない理由 ◆ UWorldはAddToRootされ、RootSetとされることでGCされない (全てのUObjectはAddToRoot出来るが、特殊な用途を除いて使用を避ける)
UObjectのライフサイクルおさらい ◆ 任意タイミングで破棄する場合、Pending Killという中間状態を経由する ◆ GCによって解放されるとUPROPERTYで保持されたポインタはnullptrにな る ※ 有効な参照が無いUObjectはPending Killを経由せず直接解放される
IsValid のすすめ ◆ 有効チェックは、 if (IsValid(Object)) とすべき if (Object != nullptr) … では、PendingKillを見分けていない ヌルチェックだけで絶対有効と確信があればいいが、迷ったらIsValid
BPのIsValid ◆ BPも同じIsValidを呼ぶ BPでも参照前にIsValid関数で有効性をチェックする事により NoneとPendingKillを区別する必要がなくなる
もし、別の場所でアクタが明示的に破棄されたら ◆ たとえば、OtherActorがどこかでDestroyActorされる場合、ANiceExample からは以下の経緯を辿ることを観察できる 1. OtherActor != nullptr で、IsPendingKill() == false (生きている) 2. OtherActor != nullptr で、IsPendingKill() == true (DestroyActorされた) 3. OtherActor == nullptr (GCで回収された)
GCを強制的に起こすと... ◆ obj gcを入力すると、参照されていない、又はPendingKillのUObjectが即時 解放される ◆ この時、PendingKillのUObjectを参照している全てのUPROPERTYにnullptr が代入される
ここまでのまとめ 1. UObjectはRootSetを起点に数珠つなぎにすることでGCから守る 2. 参照数とは無関係に、任意のタイミングでUObjectを”Destroy”できる 3. PendingKillという、C++的に有効だがUE4の取り決めとして無効な状態が ある 4. GCされると、UPROPERTYは自動でnullptrになる
使い方を間違った時の バグの事例集
【誤】 GCに参照無しとみなされ回収される ◆ UPROPERTYをつけないのは、2つの意味で誤り 1.参照保持されない 2.GC後ダングリングポインタになる
【正】 UPROPERTYをつける ◆ UPROPERYTをつけると参照が保持され、GC実行後ダングリングポインタ 化もしない ※ 補足:自分で管理するUObject以外を参照する場合もUPROPERTYはつける。ダングリ ングポインタ化する危険が伴うため。
【誤】 GCから見えないUPROPERTY ◆ ContainerにUPROPERTYがついていないと、その中の構造体の UPROPERTYはGCから辿れないので、参照維持の能力がない
【正】 GCから辿れるようにする ◆ ContainerにUPROPERTYをつけるとGCがMyObjectまで辿れるようになる
【TIP】 ダングリングポインタを検知する方法 ◆ IsValidLowLevelはダングリングポインタが疑われるUObjectを検証 falseを返したら、バグと判定できる 【注意1】デバッグ用であり、製品に組み込んではいけない! 【注意2】無効を有効と誤判定する可能性有り(GC後同一アドレスの別のUObjectが生ま れた場合)
【誤】タイマーに生ポインタを渡す例 ◆ この例はラムダ関数内でthisがダングリングポインタに
【正】 TWeakObjectPtrとしてUObjectを受け取る版のSetTimerを使う ◆ WorldTimerManagerに限らず、’DELEGATE’にはUObjectへのポインタを引数 に取るオーバーロード関数が用意されている ◆ thisはTWeakObjectPtrに変換され、タイマー呼び出し前のUObjectの寿命チェ ックが行われる
【正】 UE4.22以上ならCreateWeakLambdaが使用可能 ◆ thisがTWeakObjectPtrとして寿命チェックされつつ、ラムダでキャプチャ ◆ コールバック用のUFUNCTIONを用意せずとも良くなり、コードが簡潔に ※ UE4.21にもCreateWeakLambdaの定義があるがコンパイルが通らない
【TIP】 TWeakObjectPtr ◆ いつ破棄されるかわからないUObjectを安全に参照するなら、生のポインタ(アドレス 値)の代わりにTWeakObjectPtrを使用(構造体の中などにも定義可能) ◆ Delegate、タイマー、コリジョンの実装でも使われている
【誤】デストラクタにコードを書く
【正】 BeginDestroyかFinishDestroyに書く ◆ 安全を考えると、UObjectはデストラクタを実装すべきではない ◆ デストラクタ内でのみ、UPROPERTYがダングリングポインタになり得る 解放予定のメモリとして、nullptrを代入する事も省略されているため ◆ デストラクタの用途が必要なら、代わりにBeginDestroyかFinishDestroyを 実装する(UPROPERTYに安全にアクセス可能)
【誤】 シングルトン ◆ RootSetから繋がっていないので、いずれGCに回収されてしまう ◆ MyInstanceの存在はGCが認知しないので、回収と同時にダングリングポ インタになる
【正】 シングルトン ◆ 属するRootSetが無いので、シングルトン自身をAddToRoot
シングルトンについて捕捉 ◆ 上のAddToRootの例は、PIE/SIEの開始や終了とは無関係にシングルトンが存続 することに注意 ◆ PIE/SIE中など、ゲーム中にのみ存在するシングルトンを意図する場合、 GameInstanceのUPROPETYで保持するなど、GameInstanceの寿命と連動させる
【誤】TSoftObjectPtr、レイジーロード後GCされる ◆ BeginPlayでロードさせたが、いつの間にかGCされる現象が発生する (PIEでは回収されず生きていたりする為、スタンドアロンで発覚する事も)
【正】別途参照保持させるためのUPROPERTYを定義 ◆ LoadSynchronousは、ロードはするがその後参照を保持することを意味しな い ◆ 別途、参照保持を講じる
【TIP】 TSoftObjectPtrとTWeakObjectPtrの違いは? ◆ どちらも弱参照(GCを防がない) ◆ TWeakObjectPtrはランタイムのUObjectを参照する ◆ TSoftObjectPtrはアセットに結びついている ファイルのパスを保持している ⚫ UPROPERTY(EditDefaultsOnly) 等を指定して、エディタから設定できる ❖ UPROPERTYを書いてもGCを防げないことに注意! ⚫
まとめ ◆ TWeakObjectPtrは参照先のUObjectが破棄されても安全 ポインタとしてUObjectのアドレス値だけ渡すのは危険 ◆ デストラクタは書かないほうが良い ◆ TSoftObjectPtr は弱参照
全体のまとめ
UObjectについての全体のまとめ ◆ Unreal C++で書くと、new/deleteの出番はあまりなく、ほぼUObject ◆ C++らしくない独特の仕様と構文なので、学習コストは掛かる ◆ ゲーム制作に特化 ◆ 慣れると少ないコードで効率的かつ安全にリソースを管理できる
おまけ
BeginDestroyとFinishDestroyに分かれている意味は? ◆ 主にグラフィックリソースの管理に利用されている ◆ BeginDestroyはすぐ実行される ◆ レンダースレッドやGPUが参照しているリソースはすぐに捨てられない為、 解放が可能な時まで待ってからFinishDestroyが実行される
PendingKillかどうかデバッガから見たい ◆ InternalIndexを65536で割り、 GUObjectArray.ObjObjects.Objects[<商>][<余り>] をWatchに追加 デバッガを16進表示にして上4ケタを<商>、下4ケタを<余り>の位置に入力 正しく入力できると“Object”が対象のオブジェクトを指す事が確認できる “Flags”に0x20000000のビットがあればPendingKill(EInternalObjectFlags) エディタバイナリの場合はモジュール(dll)が分かれているので、デバッガには UE4Editor-CoreUObject.dll!GUObjectArray.ObjObjects.Objects と入力
シリアルが桁あふれしたら? ◆ TWeakObjectPtrがUObjectの同一性チェックに使うシリアルは32ビット ◆ あふれるとFatalで停止する ◆ ただし、全てのUObjectにシリアルが割当たるわけではなく、初めて TWeakObjectPtrに参照された瞬間に割り当たる アクタ、コンポーネント等は無条件に割り当たる ◆ 十分に大きい値であるので、桁あふれは想定せずとも良いかもしれない (現実的な起動時間内に桁あふれを起こす頻度でUObjectを作る事による性能低下が先に問題になると思われる)
AddReferencedObjectsで参照を維持 ◆ UPROPERTYを書かず参照を維持する方法 ◆ UPROPERTY同様、破棄されたらnullptrを代入してくれる
デバッグ用にアロケータを変えてみる ◆ 不正なメモリアクセスを疑ったら、-stompmalloc オプション UE4はデフォルトで独自の高速なアロケータを使う 効率は良いが、不正なメモリアクセスが起きた時点で例外を発生させられない (クラッシュ箇所がバグの原因ではない事がしばしば) ◆ -stompmalloc 1アロケート毎にOSにページを要求する https://pzurita.wordpress.com/2015/06/29/memory-stomp-allocator-for-unreal-engine-4/ ◆ -stompmalloc が重すぎるなら -ansimalloc で問題箇所を捕捉できる場合も malloc/freeを直接使うようになる
-stompmallocは結構重い ThirdPersonサンプルを-stompmallocオプションで開き、タスクマネージャで確認 -stompmallocなし -stompmallocあり