エディタのUXを向上させるUnreal C++との付き合い方

80.5K 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/

profile-image

Unreal Engine をメインとするゲーム会社、株式会社Leon Gameworks のアカウントです。

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

関連スライド

各ページのテキスト
1.

エディタのUXを向上させる Unreal C++との付き合い方 スクワッドスターズ株式会社 シニアプログラマー 野中 和廣 / Kazuhiro Nonaka

2.

この資料は いくつかの動画を含みます! PDF や Web 上では再生されないので、 ダウンロードしてからの閲覧を推奨します

3.

動機 エンジンで用意されている UCLASS や UPROPERTY に 設定できるタグは数多く、覚えるのは大変です。 しかしこれらには設定するだけで使える、 便利な機能がたくさん用意されています。 プロパティ指定子などのメタ情報を中心に、 実装事例や役に立つ(かもしれない) Unreal C++ について紹介します。

4.

自己紹介 スクワッドスターズ株式会社 シニアプログラマー 野中 和廣(@koorinonaka) サーバーからクライアントへ転生して はや5年くらい。はやくゲーム出したい。

5.

目次 ► Instanced Property ► 仕様と実装方法 ► 例外処理 ► エラー処理のベストプラクティス ► データ検証(DataValidation)

6.

Instanced Property について 見たことありますか?

7.

► クラスを選ぶとオブジェクトが インスタンス化 される ► オブジェクトのプロパティが公開され、外部から設定できる どういった場面で便利なのか?

8.

例えば Trigger Volume への接触時にイベントを発生させる という実装で考えてみましょう

9.

プレイヤーがトリガーに接触すると 指定のエネミーをスポーンさせる というイベントを発生させるボリュームを作ります

10.

するとそこに 指定の BGM を再生したい という要望が追加されました

11.

さらに 指定のゲーム進行会話を再生する という要望が追加されました

12.

ゴチャぁ・・・

13.

問題点 ► 関連性のない処理を一つにまとめると、 所謂 God クラスになってしまう ► しかしクラスを分割すると、 各ボリュームを同一の領域で配置することになってしまう

14.

つまりどうする? ► 接触したときに何らかの処理を実行する ► OnComponentBeginOverlap ► のインターフェースを持つ しかし処理の実装&必要な入力は自由に決めたい ► サブクラスごとに実装できるようにする これを解決するのが Instanced Property

15.

► 必要なイベントのみを設定する ► イベントごとのプロパティが公開され、 外部から自由に設定できる

16.

柔軟で変更に強い設計になった!

17.

次の例 Trigger Volume のイベント発生条件 での運用を考えてみましょう

18.

イベントの発生条件を設定したい トリガーに接触したアクターが ► プレイヤーが操作するキャラクターである ► 且つサーバーでのみ判定する

19.

問題点 ► 設定する条件は場面(配置、トリガー)ごとに異なる ► 仕様により条件は複雑化する場合が多い ► 継ぎ足し作っていくのは頭が痛い ► 仕様が増えるごとに BP 修正するの? これも解決するのは Instanced Property

20.

► サーバーでのみ実行する ► 接触したキャラがプレイヤー操作である ► 指定のイベントが発生中

21.

仕様のまとめ プロパティをポチポチするだけ 簡単に設定することができます

22.

Instanced Property の実装 Unreal C++ の実装について

23.

抽象基底クラスの作成 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 のイベント発生条件クラスを作成します

24.

C++ で派生クラスを作成する場合 UCLASS( DisplayName = "from C++" ) class UTriggerVolumeCondition_CPlusPlus : public UTriggerVolumeCondition { GENERATED_BODY() public: virtual bool TestCondition_Implementation( UPrimitiveComponent* OtherComp ) const override; }; BlueprintNativeEvent 指定子により、 関数名_Implementation() という関数が生成される

25.

BP で派生クラスを作成する場合 BlueprintNativeEvent 関数をオーバーライドして実装する

26.
[beta]
使い方はこんな感じ
UPROPERTY( EditAnywhere, Instanced )
TObjectPtr<UTriggerVolumeCondition> Condition;

if ( Condition->TestCondition( OtherComp ) )
{
// イベント処理を実行
}

Instanced 指定子プロパティを C++ で定義する

27.

Abstract(抽象化) UCLASS( Abstract, Blueprintable, EditInlineNew, CollapseCategories ) class UTriggerVolumeCondition : public UObject Abstract 指定子によって、このクラスを「抽象基本クラス」 として宣言し、このクラスのアクタがレベルに追加されない ようにします。 ► 基底クラスを Abstract 化することにより、 自身は実体化されずインターフェースのみを提供します ► インターフェース(サブクラスへの実行ルール) だけを決めて、その先は自由に実装することができます

28.

Blueprintable UCLASS( Abstract, Blueprintable, EditInlineNew, CollapseCategories ) class UTriggerVolumeCondition : public UObject ブループリントの作成に使用できる基本クラスとしてこのクラスを公開 します。他のものを継承していない場合、初期設定は NotBlueprintable となります。この指定子はサブクラスで継承されます。 ► C++ クラスを継承してブループリントを作成します ► どう使用するかは プロジェクトの指針と開発者の好みによりますが、 変更が多いゲーム仕様はイテレーションの強みを生かせるので、 BP での実装もオススメ

29.

とはいえ BP で書くロジックは読みにくいので、 部分的な C++ 化(汎用化)を検討するとよい 個人的にはズーム -6(適当)で 画面全体が読めるくらいで考えてます

30.

EditInlineNew UCLASS( Abstract, Blueprintable, EditInlineNew, CollapseCategories ) class UTriggerVolumeCondition : public UObject このクラスのオブジェクトは、既存のアセットから参照されるの ではなく、アンリアル エディタのプロパティ ウィンドウから作成 できることを示しています。 ► エディタのプロパティ編集から NewObject できるようになる ► 後述の Instanced と組み合わせるための指定子

31.

protected: UPROPERTY( EditAnywhere, Category = Test, Instanced ) TObjectPtr<UEditInlineNew> Instanced; UPROPERTY( EditAnywhere, Category = Test ) TObjectPtr<UEditInlineNew> NotInstanced; NotInstanced の場合は、アセットを指定するのと同じ扱い

32.

CollapseCategories UCLASS( Abstract, Blueprintable, EditInlineNew, CollapseCategories ) class UTriggerVolumeCondition : public UObject このクラスのプロパティは、アンリアル エディタのプロパティ ウ ィンドウのカテゴリでグループ化されません。 上が CollapseCategories 指定子を適用した状態 カテゴリ表示を省略することができる

33.

BlueprintNativeEvent この関数は ブループリント によってオーバーライドされる設計と なっていますが、デフォルトのネイティブの実装もあります。 UFUNCTION( BlueprintNativeEvent ) bool TestCondition( UPrimitiveComponent* OtherComp ) const; virtual bool TestCondition_Implementation( UPrimitiveComponent* OtherComp ) const PURE_VIRTUAL(, return false; ); ► C++ とブループリントで、 同一のインターフェースを定義するための指定子 ► ブループリントで同名の関数を上書きすることができる

34.

Object->TestCondition( OtherComp ); 1. BP の関数をチェック 2. なければ C++ 関数を実行 virtual bool TestCondition_Implementation( UPrimitiveComponent* OtherComp ) const { }

35.

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; ); 派生クラスで必ず実装すべき関数を定義する

36.

ちょっと不便・・・ ► Native C++ の純粋仮想関数と違い、呼び出し時に 初めて評価される(コンパイルではエラーにならない) ► そして Fatal Error でクラッシュする

37.

DisplayName UCLASS( DisplayName = "from C++" ) class UTriggerVolumeCondition_CPlusPlus : public UTriggerVolumeCondition ブループリント スクリプト内のこのノード名は、コードが生成す る名前の代わりにここで指定する値で置き換えられます。 これを指定しないとクラス名がそのまま表示される

38.

BlueprintDisplayName を設定する BP 派生クラスの場合はアセット名が表示される こちらもユーザーフレンドリーな名前をつけることをおススメ

39.

要件はゲーム仕様によって様々! ブループリントを活用していきましょう

40.

少し補足 Tips とかいろいろ

41.

NewObjectの引数、Outerについて template <class T> T* NewObject( UObject* Outer = ( UObject* )GetTransientPackage() ); ► エディタ上でインスタンス化する場合 ► インスタンスを作ったアセット自身が ► Outer になる C++ で NewObject する場合 Outer が Null になり、 データが保存されない ► 引数で指定しないと ► Instanced Property はシリアライズのために Outer を指定する必要がある

42.
[beta]
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について調査してみた - ハッカーと同人作家

43.

BP 派生クラスの WorldContextObject PrintString や GetGameInstance など、いくつかのノードで WorldContextObject という入力ピンが表示される場合がある これは簡単に言えば UWorld への参照を辿るための Object である

44.

UFUNCTION(BlueprintPure, Category="Game", meta=(WorldContext="WorldContextObject")) static ENGINE_API class UGameInstance* GetGameInstance(const UObject* WorldContextObject); meta=(WorldContext="") の指定子を追加することで、 アクターなど一部のクラスでは、 自動で自身が入力されピン表示は省略される

45.
[beta]
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 での挙動を変えるだけでよい

46.

BP エディタでは Instanced 化できない BP エディタ上で作成したプロパティは通常のオブジェクト扱い UCLASS のメタデータ DefaultToInstanced も効果なし

47.
[beta]
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 を使いましょう

48.

ここから応用編 さらに変な使い方とか

49.

この実装だと配列内の AND 条件しか指定できなくない? ► サーバーでのみ実行する ► 接触した操作キャラがプレイヤーである ► または味方NPCも許可する ► イベント中である 開発中はこういう変更がよく発生する

50.

AND OR 条件を作る 配列を条件の入れ子に移動(なんだかごちゃごちゃしてきた・・・) ともあれ仕様変更には強い設計に!

51.

汎用的な float 値判定を作る (float A) <=> (float B) ► キャラクター A/B 間の距離を判定する ► ステータスの閾値(最大 HP 半分以下など)を判定する 演算子を追加すれば、汎用で柔軟な条件が実装できそうでは?

52.

比較演算子や Evaluator を追加

53.

比較演算子や Evaluator を追加 1. Actor から Location を取得 2. Location 間の距離を計算 3. 距離を右辺値と比較

54.

やりすぎか?w

55.

こんな風にできたら PropertyAccess みたいにインラインで書けるようになるといいなぁ

56.

InstancedStruct が実装されました UPROPERTY( EditInstanceOnly, Category = Parameter, meta = ( BaseStruct = "/Script/[ModuleName].[StructName]", ExcludeBaseStruct ) ) FInstancedStruct Condition; [UE5] FInstancedStruct を使ってみる|株式会社ヒストリア

57.

使い勝手は Instanced Object とさほど変わらない ► USTRUCT の構造体を継承する ► GC の対象ではない ► 当然 ► UFUNCTION など使えない しかし DataTable でも使える ► Experimental なので注意

58.

例外処理 例外とどう付き合っていくのか

59.

エラー処理どうしてますか? UObject* Object = FindObject(); Object->Execute(); // クラッシュするかも!! 例外処理が正しくできてないと? ► 予期せぬ動作が生まれる ► クラッシュしたりバグの原因になる

60.

if 文で分岐してみる UObject* Object = FindObject(); if ( !Object ) { return; } Object->Execute(); // クラッシュしないことが保証された 不具合を修正しました!これで安心?

61.

if return の問題 UObject* Object = FindObject(); if ( !Object ) { return; } ► 本来の正常処理が走らない ► 見かけは問題なく動作してそうな場合もある ► エラーが発生したことに気づけない

62.
[beta]
ログを出そう
UObject* Object = FindObject();
if ( !Object )

{
// ある程度は特定できるように、状況や情報を追加するとよい
UE_LOG( LogTemp, Warning, TEXT( "%s: Object is null!" ), *UKismetSystemLibrary::GetDisplayName( this ) );
return;
}

Object->Execute();

アウトプットログに表示される

63.

アウトプットログ見てますか? デザイナーやアーティストは基本的に見ない なんならプログラマでも見てない

64.
[beta]
ゲーム画面にも表示する
UE_LOG( LogTemp, Warning, TEXT( "%s" ), *Msg );

constexpr float TimeToDisplay = 10.f;
GEngine->AddOnScreenDebugMessage( INDEX_NONE, TimeToDisplay, FColor::Yellow, Msg );

まぁ多少は気づくようになる

65.
[beta]
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

エラー箇所を特定するために、ファイル情報を埋め込む
マクロを使えば行数が変わっても安心

66.

深刻なエラーが発生したら? ► ゲームモードが正しく設定されていない ► プレイヤー操作キャラがスポーンできなかった つまりゲームが進行不能になった場合

67.

ゲームを強制終了する メッセージログを開いてログを表示する

68.
[beta]
#if WITH_EDITOR
if ( GEditor )
{
FMessageLog( "PIE" ).Warning( FText::FromString( Msg ) );
GEditor->RequestEndPlayMap();
}
#else
checkf( false, TEXT( "%s" ), *Msg );
#endif

►

この辺はプロジェクトの指針によるが・・・

►

そもそもエラーを放置させないことが大切

69.

check? checkf( false, TEXT( "%s" ), *Msg ); ► 条件が正しいかどうかを確認するアサーションマクロ ► false の場合はログ出力し、プログラムの実行を中断する ※リリースビルドでは無効 Unreal Engine での assert | Unreal Engine 5.3 ドキュメント

70.

check? Void Remove( int32 Index ) { check( Index >= 0 ); } 開発者の意図を記述する ► 開発者の想定としてはこうなるはずを明示する ► 他者にも読みやすいコードになる ► check で失敗する場合は、設計を見直す

71.

エラー処理に役立つ Unreal C++ Unreal C++ で実装されたモダンなクラスたち

72.

Null 比較できない型はどうする? bool FindLocation( FVector& OutLocation ) const { if ( Condition ) { OutLocation = FVector::ZeroVector; return true; } return false; } FVector TargetLocation = FVector::ZeroVector; if ( !FindLocation( TargetLocation ) ) { // 有効な値が返ってきたかどうか判定したい } 参照渡し&返り値で有効判定を取得する

73.
[beta]
TOptional
optionalクラスは、任意の型Tの値を有効値として、あらゆる
型に共通の無効値状態を表現できる型である。
TOptional<FVector> FindLocation() const
{
if ( Condition )
{
return FVector::ZeroVector;
}
return NullOpt;
}

TOptional<FVector> TargetLocation = FindLocation(); // 呼び出しがシンプルになった
if ( !TargetLocation )
{
return;
}

74.
[beta]
関数内のエラーを外部で知りたい
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;
}

エラー原因を関数外部で受け取ってログ出力する

75.
[beta]
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;
}

76.

データ検証(DataValidation) アセットの検証機能

77.

DataValidation? ここまでは Runtime での例外処理 そもそもアセット作ったときにエラーを検出したいよね って目的で使われるのが DataValidation です

78.

どんな機能? ► Actor や Component、ブループリントなどの アセットでデータを検証する ► プロパティ抜けや設定ミスなど、 開発者がルールを定義してエラーを出力する ► アセット保存やクック時、 スクリプトから呼び出すなどで処理が実行される

79.
[beta]
プロジェクトクラスのデータ検証
#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 をオーバーライドする

80.

Tips とか ► 2つの結果を比較する場合、CombineDataValidationResults を使うと便利 Valid + Invalid = Invalid など ► パッケージレベルでしか呼ばれないため、 サブオブジェクトの呼び出しは自前で実装する必要がある ► WITH_EDITOR 関数なので注意(よく CI に怒られる…)

81.

エンジンクラスのデータ検証 EditorValidator クラスを使う ► StaticMesh や Texture など ► EditorValidatorBase を継承した エディタユーティリティブループリントを作成する ► エディタを再起動しないと登録されないので注意 UE4.23から入った「Editor Validator Subsystem」を使って、アセ ット保存時などで走るチェック処理(Validate Assets)を拡張し よう! - ぼっちプログラマのメモ

82.

EditorValidatorBase::CanValidateAsset 検証アセットのフィルタ関数を実装する 全てのアセットがこの関数を実行するため、 クラスやフォルダ階層などでフィルタする

83.

EditorValidatorBase::ValidateLoadedAsse t

84.

AnimNotify のデータ検証 AnimSequence(Montage)に埋め込む AnimNotify ですが、 その多くは手作業によって設定する必要があります 当然ミスも多くなりがちですが、 設定者(デザイナー)がエラーの原因を特定するのは困難です このような手作業のミスを減らすために DataValidation は役立ちます

85.

とはいえ ► 実際はログを出せば自己解決できるかというと難しい ► エラーに気づくことが大切 ► 報告&相談、放置しない

86.

検証ルールはプロジェクトによって千差万別 みんなで育てていきましょう

87.

まとめ ► Instanced Property で柔軟な設計を実現する ► Instanced ► Struct も Experimental だけど便利 例外処理を適切に扱い、堅牢な実装を実現する

88.

ありがとうございました