83.7K Views
March 02, 24
スライド概要
講演者:野中 和廣
「Unreal Engine Meetup Connect - Vol.1 - ゲーム開発編」の講演資料です。
アーカイブ動画:
https://www.youtube.com/live/nzKB5sCBCnk?si=ujPx55_yqWyLhjZC
イベントページ:
https://leon-gameworks.connpass.com/event/305752/
Unreal Engine をメインとするゲーム会社、株式会社Leon Gameworks のアカウントです。
エディタのUXを向上させる Unreal C++との付き合い方 スクワッドスターズ株式会社 シニアプログラマー 野中 和廣 / Kazuhiro Nonaka
この資料は いくつかの動画を含みます! PDF や Web 上では再生されないので、 ダウンロードしてからの閲覧を推奨します
動機 エンジンで用意されている UCLASS や UPROPERTY に 設定できるタグは数多く、覚えるのは大変です。 しかしこれらには設定するだけで使える、 便利な機能がたくさん用意されています。 プロパティ指定子などのメタ情報を中心に、 実装事例や役に立つ(かもしれない) Unreal C++ について紹介します。
自己紹介 スクワッドスターズ株式会社 シニアプログラマー 野中 和廣(@koorinonaka) サーバーからクライアントへ転生して はや5年くらい。はやくゲーム出したい。
目次 ► Instanced Property ► 仕様と実装方法 ► 例外処理 ► エラー処理のベストプラクティス ► データ検証(DataValidation)
Instanced Property について 見たことありますか?
► クラスを選ぶとオブジェクトが インスタンス化 される ► オブジェクトのプロパティが公開され、外部から設定できる どういった場面で便利なのか?
例えば Trigger Volume への接触時にイベントを発生させる という実装で考えてみましょう
プレイヤーがトリガーに接触すると 指定のエネミーをスポーンさせる というイベントを発生させるボリュームを作ります
するとそこに 指定の BGM を再生したい という要望が追加されました
さらに 指定のゲーム進行会話を再生する という要望が追加されました
ゴチャぁ・・・
問題点 ► 関連性のない処理を一つにまとめると、 所謂 God クラスになってしまう ► しかしクラスを分割すると、 各ボリュームを同一の領域で配置することになってしまう
つまりどうする? ► 接触したときに何らかの処理を実行する ► OnComponentBeginOverlap ► のインターフェースを持つ しかし処理の実装&必要な入力は自由に決めたい ► サブクラスごとに実装できるようにする これを解決するのが Instanced Property
► 必要なイベントのみを設定する ► イベントごとのプロパティが公開され、 外部から自由に設定できる
柔軟で変更に強い設計になった!
次の例 Trigger Volume のイベント発生条件 での運用を考えてみましょう
イベントの発生条件を設定したい トリガーに接触したアクターが ► プレイヤーが操作するキャラクターである ► 且つサーバーでのみ判定する
問題点 ► 設定する条件は場面(配置、トリガー)ごとに異なる ► 仕様により条件は複雑化する場合が多い ► 継ぎ足し作っていくのは頭が痛い ► 仕様が増えるごとに BP 修正するの? これも解決するのは Instanced Property
► サーバーでのみ実行する ► 接触したキャラがプレイヤー操作である ► 指定のイベントが発生中
仕様のまとめ プロパティをポチポチするだけ 簡単に設定することができます
Instanced Property の実装 Unreal C++ の実装について
抽象基底クラスの作成 UCLASS( Abstract, Blueprintable, EditInlineNew, CollapseCategories ) class UTriggerVolumeCondition : public UObject { GENERATED_BODY() public: UFUNCTION( BlueprintNativeEvent ) bool TestCondition( UPrimitiveComponent* OtherComp ) const; virtual bool TestCondition_Implementation( UPrimitiveComponent* OtherComp ) const PURE_VIRTUAL(, return false; ); }; Trigger Volume のイベント発生条件クラスを作成します
C++ で派生クラスを作成する場合 UCLASS( DisplayName = "from C++" ) class UTriggerVolumeCondition_CPlusPlus : public UTriggerVolumeCondition { GENERATED_BODY() public: virtual bool TestCondition_Implementation( UPrimitiveComponent* OtherComp ) const override; }; BlueprintNativeEvent 指定子により、 関数名_Implementation() という関数が生成される
BP で派生クラスを作成する場合 BlueprintNativeEvent 関数をオーバーライドして実装する
使い方はこんな感じ
UPROPERTY( EditAnywhere, Instanced )
TObjectPtr<UTriggerVolumeCondition> Condition;
if ( Condition->TestCondition( OtherComp ) )
{
// イベント処理を実行
}
Instanced 指定子プロパティを C++ で定義する
Abstract(抽象化) UCLASS( Abstract, Blueprintable, EditInlineNew, CollapseCategories ) class UTriggerVolumeCondition : public UObject Abstract 指定子によって、このクラスを「抽象基本クラス」 として宣言し、このクラスのアクタがレベルに追加されない ようにします。 ► 基底クラスを Abstract 化することにより、 自身は実体化されずインターフェースのみを提供します ► インターフェース(サブクラスへの実行ルール) だけを決めて、その先は自由に実装することができます
Blueprintable UCLASS( Abstract, Blueprintable, EditInlineNew, CollapseCategories ) class UTriggerVolumeCondition : public UObject ブループリントの作成に使用できる基本クラスとしてこのクラスを公開 します。他のものを継承していない場合、初期設定は NotBlueprintable となります。この指定子はサブクラスで継承されます。 ► C++ クラスを継承してブループリントを作成します ► どう使用するかは プロジェクトの指針と開発者の好みによりますが、 変更が多いゲーム仕様はイテレーションの強みを生かせるので、 BP での実装もオススメ
とはいえ BP で書くロジックは読みにくいので、 部分的な C++ 化(汎用化)を検討するとよい 個人的にはズーム -6(適当)で 画面全体が読めるくらいで考えてます
EditInlineNew UCLASS( Abstract, Blueprintable, EditInlineNew, CollapseCategories ) class UTriggerVolumeCondition : public UObject このクラスのオブジェクトは、既存のアセットから参照されるの ではなく、アンリアル エディタのプロパティ ウィンドウから作成 できることを示しています。 ► エディタのプロパティ編集から NewObject できるようになる ► 後述の Instanced と組み合わせるための指定子
protected: UPROPERTY( EditAnywhere, Category = Test, Instanced ) TObjectPtr<UEditInlineNew> Instanced; UPROPERTY( EditAnywhere, Category = Test ) TObjectPtr<UEditInlineNew> NotInstanced; NotInstanced の場合は、アセットを指定するのと同じ扱い
CollapseCategories UCLASS( Abstract, Blueprintable, EditInlineNew, CollapseCategories ) class UTriggerVolumeCondition : public UObject このクラスのプロパティは、アンリアル エディタのプロパティ ウ ィンドウのカテゴリでグループ化されません。 上が CollapseCategories 指定子を適用した状態 カテゴリ表示を省略することができる
BlueprintNativeEvent この関数は ブループリント によってオーバーライドされる設計と なっていますが、デフォルトのネイティブの実装もあります。 UFUNCTION( BlueprintNativeEvent ) bool TestCondition( UPrimitiveComponent* OtherComp ) const; virtual bool TestCondition_Implementation( UPrimitiveComponent* OtherComp ) const PURE_VIRTUAL(, return false; ); ► C++ とブループリントで、 同一のインターフェースを定義するための指定子 ► ブループリントで同名の関数を上書きすることができる
Object->TestCondition( OtherComp ); 1. BP の関数をチェック 2. なければ C++ 関数を実行 virtual bool TestCondition_Implementation( UPrimitiveComponent* OtherComp ) const { }
PURE_VIRTUAL PURE_VIRTUALは、C++で純粋仮想関数(Pure Virtual Function)を定義するた めのマクロです。純粋仮想関数は、クラス内で関数のプロトタイプだけを宣言 し、実際の実装は派生クラスで行う仕組みです。これにより、基底クラスでの デフォルトの動作を提供し、派生クラスで必要に応じて実装を追加できます。 UFUNCTION( BlueprintNativeEvent ) bool TestCondition( UPrimitiveComponent* OtherComp ) const; virtual bool TestCondition_Implementation( UPrimitiveComponent* OtherComp ) const PURE_VIRTUAL(, return false; ); 派生クラスで必ず実装すべき関数を定義する
ちょっと不便・・・ ► Native C++ の純粋仮想関数と違い、呼び出し時に 初めて評価される(コンパイルではエラーにならない) ► そして Fatal Error でクラッシュする
DisplayName UCLASS( DisplayName = "from C++" ) class UTriggerVolumeCondition_CPlusPlus : public UTriggerVolumeCondition ブループリント スクリプト内のこのノード名は、コードが生成す る名前の代わりにここで指定する値で置き換えられます。 これを指定しないとクラス名がそのまま表示される
BlueprintDisplayName を設定する BP 派生クラスの場合はアセット名が表示される こちらもユーザーフレンドリーな名前をつけることをおススメ
要件はゲーム仕様によって様々! ブループリントを活用していきましょう
少し補足 Tips とかいろいろ
NewObjectの引数、Outerについて template <class T> T* NewObject( UObject* Outer = ( UObject* )GetTransientPackage() ); ► エディタ上でインスタンス化する場合 ► インスタンスを作ったアセット自身が ► Outer になる C++ で NewObject する場合 Outer が Null になり、 データが保存されない ► 引数で指定しないと ► Instanced Property はシリアライズのために Outer を指定する必要がある
void AMyTriggerVolume::PostEditChangeProperty( FPropertyChangedEvent& PropertyChangedEvent )
{
Super::PostEditChangeProperty( PropertyChangedEvent );
if ( PropertyChangedEvent.GetPropertyName() == GET_MEMBER_NAME_CHECKED( ThisClass, SomeTrigger ) )
{
Condition = NewObject<UTriggerVolumeCondition_Hoge>( this );
}
}
他のプロパティを変更した時 Instanced Property を
自動で生成したい場合など、NewObject の第一引数に this を渡す
UE4 Outerについて調査してみた - ハッカーと同人作家
BP 派生クラスの WorldContextObject PrintString や GetGameInstance など、いくつかのノードで WorldContextObject という入力ピンが表示される場合がある これは簡単に言えば UWorld への参照を辿るための Object である
UFUNCTION(BlueprintPure, Category="Game", meta=(WorldContext="WorldContextObject")) static ENGINE_API class UGameInstance* GetGameInstance(const UObject* WorldContextObject); meta=(WorldContext="") の指定子を追加することで、 アクターなど一部のクラスでは、 自動で自身が入力されピン表示は省略される
UWorld* UTriggerVolumeCondition::GetWorld() const
{
if ( HasAllFlags( RF_ClassDefaultObject ) )
{
// If we are a CDO, we must return nullptr instead of calling Outer->GetWorld() to fool UObject::ImplementsGetWorld.
return nullptr;
}
return Super::GetWorld();
}
解決するには、UObject::GetWorld をオーバーライドする
ClassDefaultObject での挙動を変えるだけでよい
BP エディタでは Instanced 化できない BP エディタ上で作成したプロパティは通常のオブジェクト扱い UCLASS のメタデータ DefaultToInstanced も効果なし
DataTable でも Instanced 化できない
USTRUCT()
struct FMyTableRow : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY( EditAnywhere, Category = InstancedProperty )
int32 Test = 0;
UPROPERTY( EditAnywhere, Category = InstancedProperty, Instanced )
TObjectPtr<UInstancedProperty> InstancedProperty;
};
プロパティが表示されない
代わりに DataAsset を使いましょう
ここから応用編 さらに変な使い方とか
この実装だと配列内の AND 条件しか指定できなくない? ► サーバーでのみ実行する ► 接触した操作キャラがプレイヤーである ► または味方NPCも許可する ► イベント中である 開発中はこういう変更がよく発生する
AND OR 条件を作る 配列を条件の入れ子に移動(なんだかごちゃごちゃしてきた・・・) ともあれ仕様変更には強い設計に!
汎用的な float 値判定を作る (float A) <=> (float B) ► キャラクター A/B 間の距離を判定する ► ステータスの閾値(最大 HP 半分以下など)を判定する 演算子を追加すれば、汎用で柔軟な条件が実装できそうでは?
比較演算子や Evaluator を追加
比較演算子や Evaluator を追加 1. Actor から Location を取得 2. Location 間の距離を計算 3. 距離を右辺値と比較
やりすぎか?w
こんな風にできたら PropertyAccess みたいにインラインで書けるようになるといいなぁ
InstancedStruct が実装されました UPROPERTY( EditInstanceOnly, Category = Parameter, meta = ( BaseStruct = "/Script/[ModuleName].[StructName]", ExcludeBaseStruct ) ) FInstancedStruct Condition; [UE5] FInstancedStruct を使ってみる|株式会社ヒストリア
使い勝手は Instanced Object とさほど変わらない ► USTRUCT の構造体を継承する ► GC の対象ではない ► 当然 ► UFUNCTION など使えない しかし DataTable でも使える ► Experimental なので注意
例外処理 例外とどう付き合っていくのか
エラー処理どうしてますか? UObject* Object = FindObject(); Object->Execute(); // クラッシュするかも!! 例外処理が正しくできてないと? ► 予期せぬ動作が生まれる ► クラッシュしたりバグの原因になる
if 文で分岐してみる UObject* Object = FindObject(); if ( !Object ) { return; } Object->Execute(); // クラッシュしないことが保証された 不具合を修正しました!これで安心?
if return の問題 UObject* Object = FindObject(); if ( !Object ) { return; } ► 本来の正常処理が走らない ► 見かけは問題なく動作してそうな場合もある ► エラーが発生したことに気づけない
ログを出そう
UObject* Object = FindObject();
if ( !Object )
{
// ある程度は特定できるように、状況や情報を追加するとよい
UE_LOG( LogTemp, Warning, TEXT( "%s: Object is null!" ), *UKismetSystemLibrary::GetDisplayName( this ) );
return;
}
Object->Execute();
アウトプットログに表示される
アウトプットログ見てますか? デザイナーやアーティストは基本的に見ない なんならプログラマでも見てない
ゲーム画面にも表示する
UE_LOG( LogTemp, Warning, TEXT( "%s" ), *Msg );
constexpr float TimeToDisplay = 10.f;
GEngine->AddOnScreenDebugMessage( INDEX_NONE, TimeToDisplay, FColor::Yellow, Msg );
まぁ多少は気づくようになる
C++ のファイル名、行数を表示する
class FLogger
{
public:
template <typename FmtType, typename... Types>
static void Warning( const FString& File, uint32 Line, const FmtType& Fmt, Types... Args )
{
UE_LOG( LogTemp, Warning, TEXT( "%s, %s:%d" ), *FString::Printf( Fmt, Args... ), *FPaths::GetCleanFilename( File ), Line );
}
};
#define LOG_WARNING( Fmt, ... ) FLogger::Warning( __FILE__, __LINE__, Fmt, ##__VA_ARGS__ )
void Hoge()
{
LOG_WARNING( TEXT( "%s, %s" ), TEXT( "Hoge" ), TEXT( "Fuga" ) );
LogTemp: Warning: Hoge, Fuga, InstancedPropertyActor.cpp:24
エラー箇所を特定するために、ファイル情報を埋め込む
マクロを使えば行数が変わっても安心
深刻なエラーが発生したら? ► ゲームモードが正しく設定されていない ► プレイヤー操作キャラがスポーンできなかった つまりゲームが進行不能になった場合
ゲームを強制終了する メッセージログを開いてログを表示する
#if WITH_EDITOR
if ( GEditor )
{
FMessageLog( "PIE" ).Warning( FText::FromString( Msg ) );
GEditor->RequestEndPlayMap();
}
#else
checkf( false, TEXT( "%s" ), *Msg );
#endif
►
この辺はプロジェクトの指針によるが・・・
►
そもそもエラーを放置させないことが大切
check? checkf( false, TEXT( "%s" ), *Msg ); ► 条件が正しいかどうかを確認するアサーションマクロ ► false の場合はログ出力し、プログラムの実行を中断する ※リリースビルドでは無効 Unreal Engine での assert | Unreal Engine 5.3 ドキュメント
check? Void Remove( int32 Index ) { check( Index >= 0 ); } 開発者の意図を記述する ► 開発者の想定としてはこうなるはずを明示する ► 他者にも読みやすいコードになる ► check で失敗する場合は、設計を見直す
エラー処理に役立つ Unreal C++ Unreal C++ で実装されたモダンなクラスたち
Null 比較できない型はどうする? bool FindLocation( FVector& OutLocation ) const { if ( Condition ) { OutLocation = FVector::ZeroVector; return true; } return false; } FVector TargetLocation = FVector::ZeroVector; if ( !FindLocation( TargetLocation ) ) { // 有効な値が返ってきたかどうか判定したい } 参照渡し&返り値で有効判定を取得する
TOptional
optionalクラスは、任意の型Tの値を有効値として、あらゆる
型に共通の無効値状態を表現できる型である。
TOptional<FVector> FindLocation() const
{
if ( Condition )
{
return FVector::ZeroVector;
}
return NullOpt;
}
TOptional<FVector> TargetLocation = FindLocation(); // 呼び出しがシンプルになった
if ( !TargetLocation )
{
return;
}
関数内のエラーを外部で知りたい
TOptional<FVector> FindLocation() const
{
AActor* OwnerActor = GetOwner();
if ( !OwnerActor )
{
// アクターが取得できなかった
return NullOpt;
}
return OwnerActor->GetActorLocation();
}
TOptional<FVector> TargetLocation = FindLocation();
if ( !TargetLocation )
{
// DisplayNameをログに付与したい
LOG_WARNING( TEXT( "%s: failed to find location, why?" ), *UKismetSystemLibrary::GetDisplayName( this ) );
return;
}
エラー原因を関数外部で受け取ってログ出力する
TValueOrError
任意の型Tの値を正常値とし任意の型Eの値をエラー値として、
正常もしくはエラーいずれかの状態を取ることを値として表
現できる型である。
TValueOrError<FVector, FString> FindLocation() const
{
AActor* OwnerActor = GetOwner();
if ( !OwnerActor )
{
return MakeError( TEXT( "could not get owner" ) );
}
return MakeValue( OwnerActor->GetActorLocation() );
}
TValueOrError<FVector, FString> TargetLocation = FindLocation();
if ( TargetLocation.HasError() )
{
LOG_WARNING( TEXT( "%s: failed to find location, %s" ), * UKismetSystemLibrary::GetDisplayName( this ), *TargetLocation.GetError() );
return;
}
データ検証(DataValidation) アセットの検証機能
DataValidation? ここまでは Runtime での例外処理 そもそもアセット作ったときにエラーを検出したいよね って目的で使われるのが DataValidation です
どんな機能? ► Actor や Component、ブループリントなどの アセットでデータを検証する ► プロパティ抜けや設定ミスなど、 開発者がルールを定義してエラーを出力する ► アセット保存やクック時、 スクリプトから呼び出すなどで処理が実行される
プロジェクトクラスのデータ検証
#if WITH_EDITOR
EDataValidationResult UHoge::IsDataValid( FDataValidationContext& Context ) const
{
EDataValidationResult Result = Super::IsDataValid( Context );
if ( !Condition )
{
// Conditionオブジェクトが生成されていない
Context.AddError( FText::FromString( TEXT( "condition is null." ) ) );
Result = EDataValidationResult::Invalid;
}
else
{
// Conditionオブジェクト実装のIsDataValidを追加で呼ぶ
Result = CombineDataValidationResults( Condition->IsDataValid( Context ), Result );
}
return Result;
}
#endif
UObject 継承クラスでは IsDataValid をオーバーライドする
Tips とか ► 2つの結果を比較する場合、CombineDataValidationResults を使うと便利 Valid + Invalid = Invalid など ► パッケージレベルでしか呼ばれないため、 サブオブジェクトの呼び出しは自前で実装する必要がある ► WITH_EDITOR 関数なので注意(よく CI に怒られる…)
エンジンクラスのデータ検証 EditorValidator クラスを使う ► StaticMesh や Texture など ► EditorValidatorBase を継承した エディタユーティリティブループリントを作成する ► エディタを再起動しないと登録されないので注意 UE4.23から入った「Editor Validator Subsystem」を使って、アセ ット保存時などで走るチェック処理(Validate Assets)を拡張し よう! - ぼっちプログラマのメモ
EditorValidatorBase::CanValidateAsset 検証アセットのフィルタ関数を実装する 全てのアセットがこの関数を実行するため、 クラスやフォルダ階層などでフィルタする
EditorValidatorBase::ValidateLoadedAsse t
AnimNotify のデータ検証 AnimSequence(Montage)に埋め込む AnimNotify ですが、 その多くは手作業によって設定する必要があります 当然ミスも多くなりがちですが、 設定者(デザイナー)がエラーの原因を特定するのは困難です このような手作業のミスを減らすために DataValidation は役立ちます
とはいえ ► 実際はログを出せば自己解決できるかというと難しい ► エラーに気づくことが大切 ► 報告&相談、放置しない
検証ルールはプロジェクトによって千差万別 みんなで育てていきましょう
まとめ ► Instanced Property で柔軟な設計を実現する ► Instanced ► Struct も Experimental だけど便利 例外処理を適切に扱い、堅牢な実装を実現する
ありがとうございました