概要
アサーションは堅牢なプログラムを書くために有効ですが、BP(Blueprint)では残念ながら標準的なアサーション機能がありません。
コミュニティで提案されている手法含めいくつか実現方法を見つけましたが、それぞれ特徴があり、唯一の正解がないように感じました。
そこで、それらの複数の手法を現時点でどのように運用するのが良いか考察してみました。
エンジンのバージョンは5.4を使用しています。
Unreal C++におけるアサーションはヒストリアさんがまとめてくださっています。
https://historia.co.jp/archives/12181/
Unreal C++の場合は標準で十分な機能が備わっていることがわかります。これら利便性をBPで実現できないものか、以下で考察していきます。
BPでアサーションを実現する方法6選
FBlueprintCoreDelegates::ThrowScriptExceptionを使う
フォーラムのNeren69420さんの提案である、FBlueprintCoreDelegates::ThrowScriptExceptionを使うと手法で非常に使い勝手が良いアサーションを実現できることがわかりました。
この手法が優れているのは、Blueprintエディタ上でブレークしてくれるため、デバッグが容易であることです。条件を満たさなかった時、以下のように実行を停止してくれます。
Blueprint Debuggerでアサーションに失敗したときの状況を調べられます。
以下、この手法をベースとして採用させて頂きつつ、実プロジェクトで欲しかった機能を盛り込んでみます。
FBlueprintCoreDelegates::ThrowScriptExceptionはC++にあるため、BPで利用するには自作のノードを作る必要があります。今回はUnreal C++のensureのBP版を作ってみたかったため、BlueprintEnsureと名付けました。
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "AssertionFunctionLibrary.generated.h"
DECLARE_LOG_CATEGORY_EXTERN(LogBlueprintAssertion, Log, All);
UCLASS()
class UAssertionFunctionLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
UFUNCTION(BlueprintCallable, meta = (WorldContext = "WorldContextObject", DevelopmentOnly))
static void BlueprintEnsure(UObject* WorldContextObject, bool bCondition, FText Message);
};
#include "AssertionFunctionLibrary.h"
#include "Blueprint\BlueprintExceptionInfo.h"
DEFINE_LOG_CATEGORY(LogBlueprintAssertion);
void UAssertionFunctionLibrary::BlueprintEnsure(UObject* WorldContextObject, bool bCondition, FText Message)
{
if (!bCondition)
{
FFrame& StackFrame = *(FBlueprintContextTracker::Get().GetCurrentScriptStackWritable().Last());
UE_LOG(LogBlueprintAssertion, Error, TEXT("%s"), *Message.ToString());
UE_LOG(LogBlueprintAssertion, Error, TEXT("%s"), *StackFrame.GetStackTrace());
#if !(UE_BUILD_SHIPPING || UE_BUILD_TEST) || USE_LOGGING_IN_SHIPPING
FBlueprintExceptionInfo BreakpointExceptionInfo(EBlueprintExceptionType::NonFatalError);
// FBlueprintExceptionInfo BreakpointExceptionInfo(EBlueprintExceptionType::AbortExecution);
// FBlueprintExceptionInfo BreakpointExceptionInfo(EBlueprintExceptionType::Breakpoint);
FBlueprintCoreDelegates::ThrowScriptException(
WorldContextObject,
StackFrame,
BreakpointExceptionInfo);
#endif
}
}
アサーションに失敗したとき同時に任意のメッセージをログに出力できるようにしました。UObjectのピンを隠すためWorldContextを指定しています。Shipping時はアサーションのコードを除去したいのでDevelopmentOnlyを指定しています。
BPのデバッガでブレークさせるためには、Blueprint Break on Exceptionsにチェックを入れる必要があります。
Blueprint Break on Exceptionsはエディタ設定にあります。エディタ設定をソースコントロールで共有するため、Set as Defaultを押してDefaultEditorPerProjectUserSettings.iniに設定を書き出しておくことをおすすめします。
Blueprint Break on Exceptionsは本記事の用途に限らず、常にONにしておくのが良いでしょう。BPにおけるエラーの発生時にブレークされるため、エラーが見過ごされるのを防ぐと同時にBlueprint Debuggerによるエラー発生時のデバッグを可能とします。
エディタ環境でデバッグするにはとても使い勝手が良いです。一方、エディタ外では当然ブレーク出来ないためエラーに気づかれにくそうです。ただしそれは仕方ないので、できることをします。
まず、BPのスタックトレースをログに出力します。上のコードのGetStackTraceがそれです。また、NonFatalError(後述)を指定します。すると、アサーションが発生したBPクラスのインスタンスの情報がログに出力されます。
ログを見てみます。LogBlueprintAssertionカテゴリで任意メッセージ、コールスタックが出力されています。またLogScriptカテゴリでアサーションが発生したBPクラスのインスタンスの情報が出力されています。
FBlueprintExceptionInfoのコンストラクタの指定値により、ブレーク時の動作を変えられます。Breakpointはブレークするだけ、NonFatalErrorはBPクラスのインスタンスの情報をログに出力、AbortExecutionはBPクラスのインスタンスの情報をログに出力後、BPの実行を中断します。ここはプロジェクトの需要に最も合う指定値を選ぶとよいでしょう。FBlueprintCoreDelegates::ThrowScriptExceptionにその実装があり、各指定値がどう解釈されるかを確認できます。今回はNonFatalErrorを指定しました。
以上の変更により、アサーション失敗時にバグ特定に有用な情報がログに出力されるようになりました。検証のためDevelopmentビルドでパッケージもしてみましたが、ログにこれら情報が出力されていました。
この機能はプラグインにするとよさそうです。汎用的であり、エンジンを除く他のモジュールと依存関係をもたないためです。
Blueprint Macroで作る
Polysiensさんの手法は、アセットだけで実現されていました。
https://www.youtube.com/watch?app=desktop&v=zoxjZXRSYww
ブレークはせず、ログにエラー内容とBPのコールスタックを出力します。パッケージ後の環境で実行時にもエラー詳細を得られること、C++を使わないためBP主体の比較的規模の小さいプロジェクトで使いやすそうな事、アサーションの類型を気軽に追加できる事がメリットと感じました。動画では3種の類型が解説されており、応用の幅の広さを感じさせられます。
実は、前項ソースコードで詳細情報をログに出力する手法はこの動画から着想を得ました。
Blueprint Function LibraryではなくBlueprint Macro Libraryで作る事が肝です。マクロはコンパイル時にコール元の関数に展開されます。展開されることの利点はマクロ内で行われているStack Trace関数のコールの際に活きてきます。
Stack Trace関数はBPのスタックトレースをログに出力します。もし、アサーションをマクロではなくBPの関数として組んでしまうと、その関数自身の名前も出力してしまいます。それを避けるためマクロにするのが良いです。
まとめると、アサーション実現のためC++を使えない事情がある場合、もしくはアサーション類型を追加して発展させていきたい場合、Blueprint Macroが強力なツールとなりそうです。
Unreal C++のcheckを使う
Copilot(Bing Chat)に聞いてみたところ、C++でcheckを呼ぶノードを書いてBPに公開する手法を紹介されました。プログラムを停止する必要がある致命的な状況のアサーションに使えそうです。前述のとおりエラーをログに出力するのみではログが見逃されてしまう可能性がありますが、checkは見逃されることを確実に防げる手法と言えそうです。
AFunctionalTestのアサーション
AFunctionalTestを利用したテストに用途が限られますが、標準機能としてアサーションのための関数群が提供されており、BPからもコール可能です。
アサーションに失敗すると、テストが失敗するようになっています。
AFunctionalTest::AssertTrue, AFunctionalTest::AssertIsValidなどの名前で関数が多数定義されているのでFunctionalTest.hを眺めてみてください。
BPのブレークポイントを使う
プロジェクト都合で標準機能以外は使いにくい場合、PrintTextにBPのブレークポイント置くという手法でも目的を達成できるかもしれません。
上の例では、Level Sequence Actorの取得に失敗した場合にPrintTextでログを出力していますが、PrintText自体にブレークポイントを置いてあります。
BPへのブレークポイント配置はBP自体の編集の一部とみなされるので、保存してソースコントロールにサブミットできます。つまり一度仕込んでおけばPIEで実行する限り、プロジェクト期間中に発生するエラーを捕まえ続けられます。
BPのエラーをあえて起こす
間違った使われ方をした場合、あえてBPのエラーが起こるように書くのも一つの方法です。
例えばあるUObjectが有効かどうかを調べる際、あらゆる場所でIsValidで確認すると煩雑になるため、いっそBPのエラーが起こるにまかせてしまうということです。BPのデバッガは強力で、エラー発生時エラーログ出力と同時にその箇所ですぐにデバッグに入ることができます。お陰で多少ルーズな書き方をしてもデバッグに困らない事は多々あります。
上の例ではLevelSequenceActorを取得していますが、成否を確認せずSequencePlayerにアクセスしています。
GetActorOfClassがNoneを返すとエラーが発生します。この際前述のBlueprint Break on Exceptionsをチェックを入れておくと、ありがたいことに上の画面のようにブレークしてデバッグに入れます。
場合により有効なアプローチの一つではあるものの、そもそも作法として好ましくはないです。明示的にアサーションを書いているわけではないので、読み手に書き手の暗黙的なアサーションの意図は伝わりません。
しかしながら、BPでしっかりしたエラー処理を書くべきかどうかは判断に迷う事もあります。エラーチェックのためには追加のノードを書く必要があり、しばしば見通しの良さとのトレードオフになるためです。よって、問題を承知で意図的に本項のようにBlueprint VMのエラー検知に頼るケースは実プロジェクトでもある思われたのであえて言及してみました。
まとめ
BPにおけるアサーションを実現する複数の方法に触れ、考察を深めてみました。
まずはFBlueprintCoreDelegates::ThrowScriptExceptionを利用する事が最も有用と思われました。これを活用しつつ状況に合わせ他の手法を併用していくのが良いのではないでしょうか。
そもそもの問題として、BPにおいてエラー処理がルーズになりがちであり、その一因がアサーションに標準的な手法がない事であり、標準の不在によりルール化が困難であるという事情があるかもしれません。
この記事が、堅牢なBPを書くにあたっての現場の悩みを解決する一助となれば幸いです。