すこし厳しい Blueprint 入門

Introduction

Unreal Engine ユーザーたるもの、Blueprint は日々活用されていることでしょう。そういった皆々様方は、かつてBlueprint に入門されたことかと思いますが、Blueprint の裏側を覗きに行く機会はあまりないかもしれません。

Unreal Engine に搭載されたスクリプティング言語としての Blueprint が、はたしてどのような形で記述され、誰によって実行され、どうやって C++ と連携しているのか? そんな裏側について掘り下げ、「Blueprint に入門」してみるのが本記事です。

使えなさそうで、意外と実用できる場面もある知識だったりしますので、興味のある方はぜひ読んでみてください。「そんなこととっくに知ってるぜ」というパワー系の皆さんは、本記事の粗を探してつついてくれると大変助かります。

目次

前提

検証環境

  • UE 5.1
  • UHT は C# 実装版を利用

対象読者

  • C++ がある程度読める
  • Unreal のリフレクションに関する知識 (FPropertyがわかればよい) がある
  • 原理に興味がある

記述範囲と注意事項

本記事での範疇は、Blueprint コードの内部表現、Blueprint 実行システムの構造などに焦点を当てます。

Blueprint Graph 画面の描画や、ノードの定義方法などについてはあまり詳しく掘り下げません。

また、本記事はエンジンのソースを読むことで得られた情報に基づいており、用語などは極力ソースに準拠するようにしていますが、説明のための記事上の用語が存在することがあります。Unreal Engine 以外の一般論については通常の用語を使用しています。

プログラミング言語としての Blueprint

まず、Blueprint の実行システムについて、大まかな構造を理解しましょう。Blueprint をひとつのプログラミング言語としてみたとき、以下のような特徴を持つと言えるでしょう。

  • ドメイン固有言語 (DSL)
  • スクリプト言語
  • ノードベース・ビジュアルプログラミング言語
  • 非ネイティブコンパイル言語
  • C++ との相互運用性
  • オブジェクト指向言語
  • 明示的な型付け
  • 型安全ではない、弱い静的型付け

型などについては Wikipedia に説明を任せるとして、この後のために説明しておきたい事項について触れておきます。

ドメイン固有言語 (DSL)

Blueprint は汎用言語ではなく、特定のタスクに特化したドメイン固有言語です。このことは、Blueprint の設計にも大きく影響しています。詳しくは後述しますが、演算のような基本処理から、タスクスケジューリングのような高度な処理まで、Blueprint の実行基盤は全面的に Unreal Engine に依存しています。そのため、スタンドアロンな言語として切り離して利用するのはなかなか難しい作りになっていますが、よく言えば Unreal Engine と深く統合されていると言えます。切り離す必要もないですしね。

ところで、Wikipedia のドメイン固有言語のページを覗くと、Unreal Engine への言及があります。なんとそこで触れられているのは Blueprint ではなく Unreal Script なのですが……。加筆すべきか?

非ネイティブコンパイル言語

Blueprint がネイティブコンパイル言語ではないというのは、最終的に Blueprint で記述された処理が実行される際に、機械語となって各プラットフォームの CPU で直接実行されない( できない)ということです。

これはスクリプト言語では珍しいことではありません。独自の処理系をソフトウェアとして実装し、その処理系に向けたコードを与えれば、CPU で実行される機械語でなくともソフトウェア上で「実行する」ことは可能です。処理系がコードを解釈して、示される意味のとおりに、実際のCPUやOSを制御するように実装されていれば良いからです。 Blueprint はこのタイプの言語であり、Unreal Engine の上に実装されたソフトウェアの処理系によって解釈・実行されます。こういったコードを解釈・実行するようなソフトウェアのことをVMと呼ぶこともあります。

一方で、Blueprint はコンパイルもされる言語です。このことは上記の内容と矛盾しません。Blueprint はコンパイルされると、 Blueprint bytecode というバイト列形式の中間表現を吐き出します。上記で述べた処理系が直接扱うのは、この bytecode のほうであり、ノードによる表現は直接扱えないのです。なお、詳細は後述しますが、Blueprint bytecode を実行する処理系を Blueprint VM と言います。

このような仕組みを取っているのは、主に実行時コストを落とすためだと思われます。ノードによる表現が作成する、接続関係によるネットワーク表現は人間にとっては直感的ですが、そのまま実行するには向きませんし、扱うデータが大きくなります。そのため、より実行時に処理しやすく軽量な中間表現として bytecode を事前に生成しておき、実行時には bytecode のみを処理するという形式を取っているのでしょう。

なお、他に bytecodeのような中間表現が用いられることのメリットは、中間表現を生成するコンパイラに選択肢を持たせることができることなどがあります。中間表現の仕様さえ満たしていれば、それを生成しているのがどんなコンパイラでも、中間表現を手書きできる謎の人間でも構わないわけです。このあたりの特性を利用している言語の例としては、.NET言語( C#, F#, ....)やJVM言語(Java, Scala, Kotlin, ....)の実行系、あるいはコンパイラ基盤であるLLVMのLLVM-IR などに例があります。 Blueprint も、Blueprint VM バイトコードを吐き出す別言語のコンパイラというものを作成するのは不可能ではないはずです。

また、過去には Blueprint のコンパイルバックエンドに、C++のコードを吐き出す実装(ネイティブ化) が存在しました。こちらの公式ドキュメント を参照すると、5.1現在も FKismetCppBackend がデバッグ用に存在していることになっていますが、これは誤りであり、コード上からは完全に削除されているため、現在ではあらゆる Blueprint は bytecode として実行されます。

C++との相互運用性

C++との相互運用性は、Blueprint の極めて強力な特徴です。Unreal Engine が独自のスクリプト言語を持っていることの大きなメリットとも言えるでしょう。

通常、複数言語の混在した開発において、言語間のデータの受け渡しや関数の呼び出しは大きな課題であり、そのためのレイヤや相互運用のためのライブラリがプロジェクトに導入されることも珍しくありません。

しかし、Blueprint ははじめから Unreal Engine で使われることを想定して設計され、独自のビルドシステムや C++ 側の実装の存在を前提として実装されているので、ほとんど意識せずに C++ との相互運用が可能となっています。

このことは Blueprint の言語設計にも影響を与えているはずです。たとえば、Blueprint はかなり強くオブジェクト指向を意識した言語です。C++で定義されたクラスや、作成されたオブジェクトの階層的な構造をそのまま持ち込もうとすると、必然的にそうなるのでしょう。もちろん、ゲーム開発においてオブジェクト指向が大きな実績を持っているということもあるとは思います。

型安全ではない、弱い静的型付け

これについてはあまり詳しく触れません。Blueprint は静的型付け言語ですが、型安全性は保証されていません。 たとえば、ピンの型にWildcard(何でも入れられる)などを利用した場合、実行時に型の不一致でエラーが発生することがあります。 一方で、基本的に型を意識して扱うことができ、これもやはりC++との相互運用性を考えたスクリプティング言語としての経緯が見えるところです(そうじゃなくても型ほしいですけど)。

まあただ、Blueprint には UObject という †最強の基本クラス† がいるので、なんとも言えませんが。

Blueprint の歴史的背景と UnrealKismet

Blueprint に関するコードを読んでいくと、Kismet という語がよく登場しますので、軽く紹介しておきます。 Blueprint は、Unreal Engine 4から搭載された機能でした。しかし、Unreal Engine におけるノードベースのビジュアルスクリプティング言語の歴史は Unreal Engine 4 からではありません。

Blueprint の系譜は Unreal Engine 3 から始まっています。残念ながら私は実際に触ったことはないのですが(当時10歳)、Unreal Engine 3 にも、 UnrealKismet というビジュアルスクリプティング言語が存在しました。 そして、Unreal Engine 4 の機能として公開された Blueprint 実装のコードベースは、この Kismet の多くを引き継いだものなのです。この継承の歴史は Unreal Engine 5 になっても途絶えておらず、エンジン内の Blueprint に関連するコードの髄所に Kismet という語が認められます。また、 K2NodeK2 なども、 Kismet 2 の略であると思います。

Blueprint VM 概要

さて、Blueprint の基本的な特徴を把握したところで、Blueprint が実際に実行されている実行系の構造を見ていきます。 Blueprint の実行系とは、前述の Blueprint bytecode を読んで、それが意味するところの処理を実行してくれる実装のことです。このような解釈・実行系のことをVMと呼ぶこともあると言いましたが、Blueprint の該当する実装においてもコード上で Blueprint VM と書かれることがあり、端的でわかりやすいので、この用語を採用します。

計算モデル

Blueprint VM の特徴をつかむために、どのような計算モデルを採用しているのか知ることにはメリットがあります。 この種の VM ではスタックマシンなどの計算モデルが採用されることが多いと思いますが、Blueprint がドメイン固有言語であり、C++と協調して動く、ゲームエンジン内の VM であるという特徴が、計算モデルにも影響を与えているように思われます。

というのも、Blueprint VM はスタックマシンともレジスタマシンとも断定し難い、双方の特徴を持った独自の構造をしているように(僕の目には)見えます。 このような構造になっているのには、Blueprint VM はデータがその内部に完結する必要がないという点の影響が大きいのではないかと個人的には思っています。この種の VM を汎用的な実行基盤として構築するのならば、そこで扱う全てのデータはVMが管理するレジスタなりメモリ上なりに収められ、規定のデータ構造や操作による管理で完結していなければなりません。 しかし、Blueprint VM はC++が隣に存在することが前提の仮想機械であり、Blueprint VM が管理しないC++上に確保されているC++変数の値などに大きく依存しています。C++ はすでに完全な機能を備えた言語処理系を持つ言語ですので、Blueprint VM は単体で完全な機能を備える必要がないわけです。すると当然、独立した通常の VM 実装とは異なる点が出てくるということでしょう。

命令セット設計

汎用 VM はそれだけであらゆる処理が実行できる(チューリング完全になる)ように構築されるので、(命令セットの設計思想によりますが)四則演算や論理演算などの基本的な命令を含む完結した命令セットを持ちます。これと比較すると、Blueprint VM の設計は非常に特殊です。

たとえば、Blueprint VM は演算命令を一つも持ちません。「加算がしたければ加算処理を行う C++ 関数を呼び出せばよい」というような設計思想で作られているように思われます。実際、Blueprintにおける int32 同士の加算は、以下のように UFUNCTION() マクロで作成された C++ 関数で処理されます。

int32同士の加算
/** Addition (A + B) */
UFUNCTION(BlueprintPure, meta=(DisplayName = "int + int", CompactNodeTitle = "+", Keywords = "+ add plus", CommutativeAssociativeBinaryOperator = "true"), Category="Math|Integer")
static int32 Add_IntInt(int32 A, int32 B = 1);

KISMET_MATH_FORCEINLINE int32 UKismetMathLibrary::Add_IntInt(int32 A, int32 B)
{
	return A + B;
}

また、いかなるメモリ管理も行いません。メモリ管理は Unreal Engine の GC に依存しています。

代わりに、C++ との連携のための命令は豊富に持っています。たとえば、たった今見たような C++ で実装された関数は、 EX_CallMath [0x67] という命令によって呼び出すことができます。この命令は、ネイティブ関数として実装された UFunction も呼び出すことができる命令です。UFunction からネイティブ関数ポインタを取得し、引数や呼び出しコンテキストなどのデータと、戻り値を格納すべきアドレスをディスパッチした上で呼び出しを実行します。

Blueprint VM 命令表

ここで、Blueprint VM に搭載されている命令の一覧を提示しておきます。 使われ方がわからないとピンとこないと思いますが、後の内容を読みつつ、必要に応じて参照する形で利用してください。

かなり大きなテーブルになったので、折りたたんであります。

Blueprint VM 命令セット

(一部編集中。ソースコードのコメントを元に翻訳したり、ソースを呼んで解説を追記する形で作成していますが、間に合わなくて空欄だったりコメント原文のままの箇所があります。)

命令名 バイトコード 説明
EX_LocalVariable 0x00 関数のローカル変数を取得。
EX_InstanceVariable 0x01 オブジェクト変数を取得。
EX_DefaultVariable 0x02 コンテキストのオブジェクトのCDOを取得。
EX_Return 0x04 関数から戻る。
EX_Jump 0x06 ローカルアドレスに基づいて指定されたコード上の場所に Jump する。
EX_JumpIfNot 0x07 式が false だったならば、ローカルアドレスに基づいて Jump する。
EX_Assert 0x09 アサートを発行。
EX_Nothing 0x0B 何もしない
EX_Let 0x0F FProperty を利用して、任意サイズの値の代入処理を行う。
EX_ClassContext 0x12 CDO を利用して、指定した UClass の CDO をコンテキストとして処理を実行する。
EX_MetaCast 0x13 UClass 間のメタキャスト命令。
EX_LetBool 0x14 真偽値の代入命令。Bool 値変数は Bitfield にパックして保持されるため、専用の命令が用意されている。
EX_EndParmValue 0x15 関数の任意引数デフォルト値の終了を示すようだが、5.1現在利用されているようには見えない。
EX_EndFunctionParms 0x16 関数の呼び出し引数定義の終了。
EX_Self 0x17 Self オブジェクトを取得。
EX_Skip 0x18 スキップ可能な式を表すようだが、5.1現在利用されているようには見えない。
EX_Context 0x19 式を評価して作成した UObject のコンテキストで後続の処理を実行する。
EX_Context_FailSilent 0x1A EX_Context と同様だが、作成した UObject が無効値だった場合に例外をスローしない。
EX_VirtualFunction 0x1B 仮想関数を引数付きで呼び出す。
EX_FinalFunction 0x1C 完全に実装された通常の関数を引数付きで呼び出す。
EX_IntConst 0x1D int32 を実行スタックから読み出す。
EX_FloatConst 0x1E float を実行スタックから読み出す。
EX_StringConst 0x1F FString を実行スタックから読み出す。読み込む実行スタック上の文字列は、ヌル終端する ANSI char として扱われる。
EX_ObjectConst 0x20 UObject を実行スタックから読み出す。
EX_NameConst 0x21 FName を実行スタックから読み出す。
EX_RotationConst 0x22 FRotation を実行スタックから読み出す。
EX_VectorConst 0x23 FVector を実行スタックから読み出す。
EX_ByteConst 0x24 実行スタックを 1 byte 読み出す。
EX_IntZero 0x25 int32 の定数 0 を結果に書き込む。
EX_IntOne 0x26 int32 の定数 1 を結果に書き込む。
EX_True 0x27 bool の定数 true を結果に書き込む。
EX_False 0x28 bool の定数 false を結果に書き込む。
EX_TextConst 0x29 FText を実行スタックから読み出す。
EX_NoObject 0x2A UObject* の定数 nullptr を結果に書き込む。
EX_TransformConst 0x2B FTransform を実行スタックから読み出す。
EX_IntConstByte 0x2C 実行スタックの 1 byte の値を読み出し、int32 で取得する。
EX_NoInterface 0x2D オブジェクトを保持していない TScriptInterface を結果に書き込む。.SetObject(nullptr); を呼び出した状態。
EX_DynamicCast 0x2E UObjectの dynamic cast を実行する。スタック上から `UClass` オブジェクトを読み出し、そのクラスに続く命令で評価される `UObject` がキャスト可能か(継承関係のチェック)を行う。キャスト可能であれば、有効な値を返し、キャスト不能であれば無効値を返す。この命令内では UObject 型しか扱っておらず、C++的な dynamic cast は行っていない。UClass の保持するリフレクション情報を元に、キャスト可能かどうかに基づいて、有効ならば評価された UObject を返すが、無効ならば返さないという処理である。
EX_StructConst 0x2F UScriptStruct を実行スタックから読み出す。
EX_EndStructConst 0x30 EX_StructConst に対応して利用され、実行スタック上で EX_StructConst のデータの終了を示す。ただし、UScriptStruct から存在すべきプロパティは判明するため、実行時にはこれは利用されておらず、シリアライズなどのエディタ処理で利用されている。
EX_SetArray 0x31 渡された TArray へのセットを開始する。実行スタックから評価された Array プロパティとオブジェクトアドレス、続いて EX_EndArray が現れるまで要素の値を読み取り順次 Array に追加する。
EX_EndArray 0x32 EX_SetArray に対応して利用され、TArray のセットの終了を示す。
EX_PropertyConst 0x33 FProperty を実行スタックから読み出す。
EX_UnicodeStringConst 0x34 FString を実行スタックから読み出す。読み込む実行スタック上の文字列は、ヌル終端する UTF-16 バイト列として扱われる。サロゲートペアを考慮して読み込まれる。
EX_Int64Const 0x35 int64 を実行スタックから読み出す。
EX_UInt64Const 0x36 uint64 を実行スタックから読み出す。
EX_DoubleConst 0x37 double を実行スタックから読み出す。
EX_Cast 0x38 スタックからキャストコード(キャストする型の組み合わせと方向)を読み取り、そのコードに紐付けられたネイティブ関数を呼び出し、処理を引き継ぐ。呼び出されたネイティブ関数の中では、後続のスタック上のバイトを特定の型と見做して変換処理を実行する。この命令はBlueprint 実行処理の内部でキャストの必要が発生した際に利用されているようで、普段Blueprintを記述するときに直接利用することはない。たとえば、EX_Castには FloatToDouble の処理を行う関数があるが、Blueprint Graph 上で配置する Float to Cast のキャストノードは通常のC++関数であり、関連はない。
EX_SetSet 0x39 渡された TSet へのセットを開始する。実行スタックから評価された Set プロパティとオブジェクトアドレス、要素数を読み取り、続いて EX_EndSet が現れるまで要素の値を読み取り順次 Set に追加する。要素数が 0 であれば空の Set の作成のみが行われ、値の読み取りなしに実行スタックに EX_EndSet が現れなければならない。ただし、実装的には要素数は 1 以上かそれ未満かしか見ておらず、続く要素数ではなく追加していく Set の初期 Slack サイズを示している。
EX_EndSet 0x3A EX_SetSet に対応して利用され、TSet のセットの終了を示す。
EX_SetMap 0x3B 渡された TMap へのセットを開始する。実行スタックから評価された Map プロパティとオブジェクトアドレス、要素数を読み取り、続いて EX_EndMap が現れるまで要素の値を読み取り順次 Map に追加する。 要素の値は K, V, K, V... の順で実行スタックに積まれている。要素数が 0 であれば空の Map の作成のみが行われ、値の読み取りなしに実行スタックに EX_EndSet が現れなければならない。ただし、実装的には要素数は 1 以上かそれ未満かしか見ておらず、続く要素数ではなく追加していく Map の初期 Slack サイズを示している。
EX_EndMap 0x3C EX_SetMap に対応して利用され、TMap のセットの終了を示す。
EX_SetConst 0x3D TSet を実行スタックから読み出す。
EX_EndSetConst 0x3E EX_SetConstと対応して利用され、実行スタック上の値データ列の終了を示す。
EX_MapConst 0x3F TMap を実行スタックから読み出す。
EX_EndMapConst 0x40 EX_MapConstと対応して利用され、実行スタック上の値データ列の終了を示す。
EX_Vector3fConst 0x41 FVector3f を実行スタックから読み出す。
EX_StructMemberContext 0x42 スタック上の構造体メンバ Property と、続く命令で評価される構造体のオブジェクトを元に、Property が示すメンバのコンテキストを取得・設定する。
EX_LetMulticastDelegate 0x43 Assignment to a multi-cast delegate
EX_LetDelegate 0x44 Assignment to a delegate
EX_LocalVirtualFunction 0x45 Special instructions to quickly call a virtual function that we know is going to run only locally
EX_LocalFinalFunction 0x46 Special instructions to quickly call a final function that we know is going to run only locally
EX_LocalOutVariable 0x48 local out (pass by reference) function parameter
EX_DeprecatedOp4A 0x4A
EX_InstanceDelegate 0x4B const reference to a delegate or normal function object
EX_PushExecutionFlow 0x4C push an address on to the execution flow stack for future execution when a EX_PopExecutionFlow is executed. Execution continues on normally and doesn't change to the pushed address.
EX_PopExecutionFlow 0x4D continue execution at the last address previously pushed onto the execution flow stack.
EX_ComputedJump 0x4E 実行スタックの続く命令を評価して得られたオフセットを元にジャンプを行う。
EX_PopExecutionFlowIfNot 0x4F continue execution at the last address previously pushed onto the execution flow stack, if the condition is not true.
EX_Breakpoint 0x50 Breakpoint 命令。エディタ上のコンパイルでのみ存在し、それ以外では EX_Nothing のように振る舞う。
EX_InterfaceContext 0x51 Call a function through a native interface variable
EX_ObjToInterfaceCast 0x52 Converting an object reference to native interface variable
EX_EndOfScript 0x53 Last byte in script code
EX_CrossInterfaceCast 0x54 Converting an interface variable reference to native interface variable
EX_InterfaceToObjCast 0x55 Converting an interface variable reference to an object
EX_WireTracepoint 0x5A Trace point. Only observed in the editor, otherwise it behaves like EX_Nothing.
EX_SkipOffsetConst 0x5B A CodeSizeSkipOffset constant
EX_AddMulticastDelegate 0x5C Adds a delegate to a multicast delegate's targets
EX_ClearMulticastDelegate 0x5D Clears all delegates in a multicast target
EX_Tracepoint 0x5E Trace point. Only observed in the editor, otherwise it behaves like EX_Nothing.
EX_LetObj 0x5F assign to any object ref pointer
EX_LetWeakObjPtr 0x60 assign to a weak object pointer
EX_BindDelegate 0x61 bind object and name to delegate
EX_RemoveMulticastDelegate 0x62 Remove a delegate from a multicast delegate's targets
EX_CallMulticastDelegate 0x63 Call multicast delegate
EX_LetValueOnPersistentFrame 0x64
EX_ArrayConst 0x65
EX_EndArrayConst 0x66
EX_SoftObjectConst 0x67
EX_CallMath 0x68 static pure function from on local call space
EX_SwitchValue 0x69
EX_InstrumentationEvent 0x6A Instrumentation event
EX_ArrayGetByRef 0x6B
EX_ClassSparseDataVariable 0x6C Sparse data variable
EX_FieldPathConst 0x6D

ざっと見ても、C++ で定義された、かなり高級な型の値を実行スタックから読み出す命令や、同じく高級な型の代入命令などが多数存在します。また、特定の値を示すリテラルのように働き、固定の値を結果として書き込む命令も多いです。

Blueprint VM ≒ デカい C++ の Wrapper

繰り返し述べたように、Blueprint VM は独立した VM とは言いにくい実装になっています。それ自体が困難ですが、仮に C++ と Unreal Engine から Blueprint VM を引き剥がしてスタンドアロンで動かしたとすると、四則演算も比較もできなくなりますから、まともな処理を動かすことはできないでしょう。 代わりに、C++ や Unreal Engine のシステムと深く結びついているため、Blueprint ではコンテンツ制作はもちろん、エディタに関するスクリプティングなどについても高い自由度を提供できているといえるかもしれません。

Blueprint VM 詳解

そもそも、Blueprint の処理はどのように呼び出されるのでしょうか。UEngine は C++ のエントリポイントから起動しますから、Blueprint に処理を任せるために C++ と Blueprint を接続している仕組みがあるはずです。

この章では、Blueprint が実行される大きな流れについての説明を行います。

連携の本質は関数呼び出し

Unreal Engine 内部で Blueprint の処理が開始される場合、その多くは「Blueprint の関数を呼び出す」という操作に対応しています。 これはあらゆる処理において言えることです。Blueprint の Event についても、コンパイル時には関数に変換されており、C++ から呼び出されるときには直接的には関数として扱われています。

たとえば、Event の代表格である TickBeginPlay などは、コンパイル時に以下のような中間グラフ上の関数(Function stub)に変換されてからコンパイルされます。

また、Blueprint から C++ の処理を呼び出したいときもあります。このときにも、対応するのは C++ の関数を呼び出すという操作です。 ということで、Blueprint と C++ の連携を紐解く切り口として、相互に関数が呼び出される仕組みを見ていきましょう。

UFunction

Unreal Engine においての Blueprint / C++ の相互関数呼び出しには、 UFunction というクラスが大きな役割を果たしています。 UFunctionを一言で説明すると、「Blueprint か C++ の関数を表す関数オブジェクト」です。

C++ の関数オブジェクトといえば、Unreal C++ なら TFunction<>、STL なら <function>std::function<> などが思い浮かぶでしょう。これらは、C++ の関数様のオブジェクトを取り回すのに特化したクラスです。一方、UFunction は、Blueprint と C++ の双方で呼び出し可能な関数を表すクラスなのです。

UFunction の内側

とはいえ、C++ の関数と Blueprint の関数はコンパイル後の内部表現が全く異なりますから、完全に共通化したデータを保持しているわけではありません。以下は、UFunction のなかで関数呼び出しに関わるメンバを示した図です。

UFunctionUStruct を継承した型であり、UStruct のほうにも重要な情報が保持されています。 これらのパラメータのうち、C++ の関数と Blueprint の関数で使われ方が大きく異なるのは、 FuncScript です。

  • Func
    • C++ の関数である時にのみ有効で、それ以外の場合には nullptr。
    • C++ の関数ポインタを保持する。
  • Script
    • バイト列を保持する TArray<uint8> メンバ。
    • Blueprint の関数であるときにのみ長さが1以上となり、Blueprint バイトコードを保持する。

UFunction の呼び出し処理では、その UFunction が C++ 関数を表しているのか Blueprint 関数を表しているのかによって処理を分岐し、結果として共通した使用法で双方の関数を呼び出せるようにしているのです。

上図に示したそれ以外のメンバの役割は以下です。こちらは Blueprint / C++ 共通で利用されます。

  • FunctionFlags
    • EFunctionFlagsで定義されたフラグをビットフィールドとして保持する。C++ 関数であるかを表す FUNC_Native なども定義されており、関数の属性を判別するのに多用される。
  • NumParams
    • 関数の引数や戻り値などは、関数のパラメータとして扱われる。それらの総数を表す。
  • ParamsSize
    • 関数のパラメータが総計で占めることになるメモリ上のサイズを表す。関数呼び出しの際に、パラメータのために確保すべき領域を知るためなどに利用される。
  • ChildProperties
    • FProperty のリンクリスト。UFunction の場合は、関数のパラメータのリフレクション情報を保持する。これにより、引数の具体的な型や、オブジェクト上での配置位置オフセットなどが判明し、パラメータの利用が可能となる。

このように、UFunction は Blueprint / C++ の関数のシグニチャのための共通した表現を持ち、その処理の実体のみを個別に扱うことで、Blueprint / C++ どちらからも呼び出し可能な関数を実現しているクラスなのです。

UFUNCTION() マクロの役割 Thunk / CustomThunk

UFunction と聞いて、いつも Unreal C++ で書いている UFUNCTION マクロを付けたメンバ変数のことを連想したのに、なにか違うものの説明をされて戸惑っている人がいるかもしれません。

公式ドキュメント含め、「UFUNCTION() をつけると UFunction になる」と説明されることがありますが、厳密に言うと、UFUNCTION マクロを付けた関数自体が UFunction になるわけではありません。中身が書き換わるわけでもありませんし、実装はそのまま利用されます。しかし、 UFUNCTION マクロを付けた関数には、その関数を UFunctionFunc メンバとして取り扱い可能にする(シグニチャをあわせて引数のディスパッチを行う)ためのラッパー関数が外部に生成されます。この生成は Unreal Header Tool によってビルド前に行われます。

ラッパー関数を生成する目的は、呼び出しのシグニチャを統一することです。UFUNCTION マクロは様々な引数や戻り値を持った関数に付けられますが、 UFunctionFunc メンバが保持できるシグニチャは一定です。そこで、Func が保持できるシグニチャを持ったラッパー関数で包み、内部で引数の値や戻り値の処理を個別に行うことで、どんな関数でも Func で保持できるように差異を吸収するのです。

このラッパー関数によって、UFUNCTION マクロを付けた任意の C++ 関数を UFunction で保持することができるようになります。すると、C++ で実装した関数が Blueprint / C++ で扱えるようになるという仕組みなのです。

ちなみに、このラッパー関数のことを、Unreal Engine では Thunk function (サンク関数) と呼んでいるようです。この名前に「オッ」となった人はいるでしょうか。そうです。出会って戸惑うUFUNCTION() 指定子ランキングトップの UFUNCTION(CustomThunk) とは、このサンク関数をUHTに自動生成させることを抑制し、自分自身でカスタムのサンク関数を記述するための指定子なのです。 CustomThunkの使い方については、別の記事を書こうと思っています。

UFunctionの呼び出しと大きな流れ

UFunction はローカルだけでなくリモート(サーバーなど)で実行されることもあるので、エンジン内でも様々な方法の実行処理が記述されています。ここでは、ローカル関数に特化した呼び出しの流れを見てみます。簡単のため、一部コードは省いて、コメントを追記しました。

ScriptCore.cpp
#define RESULT_PARAM Z_Param__Result
#define RESULT_DECL void* const RESULT_PARAM

void ProcessLocalFunction(UObject* Context, UFunction* Fn, FFrame& Stack, RESULT_DECL)
{
  // UFunction の有効性をチェック
	checkSlow(Fn);
  // C++関数か?
	if(Fn->HasAnyFunctionFlags(FUNC_Native))
	{
    // C++の関数を直接呼び出し
		Fn->Invoke(Context, Stack, RESULT_PARAM);
	}
	else
	{
    // UFunction が持つ Script メンバに保持された Blueprint バイトコードを実行
		ProcessScriptFunction(Context, Fn, Stack, RESULT_PARAM, ProcessLocalScriptFunction);
	}
}

C++ 関数に対する処理

UFunction には Invoke() という、Func に保持する C++ 関数を呼び出すための専用メンバが存在します。C++ 関数に対する呼び出しは、このメンバを呼び出すだけで対応されています。

「この呼び出し方では様々な引数の関数に対応できないのでは?」と思うかもしれませんが、問題ありません。 UFunction の説明の項 で述べた通り、C++ のネイティブ関数を UFunction で利用する場合、必ずシグニチャが統一されたラッパーである Thunk 関数が存在します。 最終的に関数に渡される引数の値や、その関数が紐づくオブジェクトは、すべてここで渡されている ContextStack から Thunk 関数が取り出し、最終的な引数として決定します。 また、関数の戻り値は RESULT_DECL に格納されます。コード上部に定義を引用しましたが、RESULT_DECLvoid* const を表すので、任意の戻り値のアドレスを受けることができます。

例として、Blueprint における Int 同士の加算を定義している以下の関数を保持する UFunctionInvoke() した場合を見てみます。

Add_IntInt()
static int32 Add_IntInt(int32 A, int32 B = 1);

UHT によって以下の Thunk 関数が自動生成され、UFunctionFunc に保持されているので、Invoke() で実行されるのもこの Thunk 関数です。

Thunk関数
// static int32 Add_IntInt(int32 A, int32 B) の Thunk 関数
void UKismetMathLibrary::execAdd_IntInt(UObject* Context, FFrame& Stack, void* const Z_Param__Result)
{
	FIntProperty::TCppType Z_Param_A = FIntProperty::GetDefaultPropertyValue();
	// 第1引数を Stack.Code のバイトコードを評価して取得する。Codeが進む。
	Stack.StepCompiledIn<FIntProperty>(&Z_Param_A);;

	FIntProperty::TCppType Z_Param_B = FIntProperty::GetDefaultPropertyValue();
	// 第2引数を Stack.Code のバイトコードを評価して取得する。Codeが進む。
	Stack.StepCompiledIn<FIntProperty>(&Z_Param_B);;

	// 次の Stack.Code が nullptr でなければ、1つ Code を進める。
	Stack.Code += !!Stack.Code;;

	// 引数を C++関数にディスパッチ。値を結果格納用の引数に書き込んで終了。
	*(int32*)Z_Param__Result = UKismetMathLibrary::Add_IntInt(Z_Param_A, Z_Param_B);
}

Stack から C++ 関数に渡すべき引数の値などを読み出し、全ての準備が整ってから実際の C++関数の呼び出しを行っていることがわかります。

Blueprint 関数に対する処理

Blueprint 関数は、ネイティブ実行可能なコードではなく Blueprint VM のバイトコードなので、単純に呼び出すことはできず、逐次バイトコードを処理していく必要があります。

上のコードで利用されている ProcessScriptFunction() のシグニチャは以下です。

template<typename Exec>
void ProcessScriptFunction(UObject* Context, UFunction* Function, FFrame& Stack, RESULT_DECL, Exec ExecFtor)

ProcessScriptFunction() は、Blueprint 関数の呼び出しの前処理と、呼び出しまでを行ってくれるヘルパー関数です。 Blueprint 関数の引数や戻り値パラメータを保持するメモリ領域は、C++ の関数と異なり自動で確保されないので、呼び出す前に明示的に確保を行う必要があります。また、関数呼び出しに際しては、確保したメモリ領域や、関数の処理の実行状況、実行しているバイトコードへの命令カウンタなどをまとめて管理するスタックフレーム(のようなもの)を新たに構築する必要があります。

ProcessScriptFunction() が行う前処理の中では、渡された UFunction のプロパティ情報などを元に、必要なメモリの確保や初期化を行い、スタックフレーム(のようなもの)の構築までを行ってくれます。これは、 FFrame 型の変数として引き継がれていきます。FFrame については後ほど詳しく触れます。

第5引数の型がテンプレート型引数によって指定されており、ここに渡した関数が最後のバイトコード実行の処理として利用されます。 また、第5引数(最後の引数)に渡されているのは以下の関数です。簡単のため、例外処理は削除しています。

void ProcessLocalScriptFunction(UObject* Context, FFrame& Stack, RESULT_DECL)
{
	UFunction* Function = (UFunction*)Stack.Node;
	// No POD struct can ever be stored in this buffer. 
	MS_ALIGN(16) uint8 Buffer[MAX_SIMPLE_RETURN_VALUE_SIZE] GCC_ALIGN(16);

	// バイトコードの実行。EX_Return 命令が現れるまで実行し続ける。
	while (*Stack.Code != EX_Return)
	{
    // 後続のバイトコードを一つ読み取り、その命令を実行する。内部では処理に依存した数だけ Stack.Code が進む。
		Stack.Step(Stack.Object, Buffer);
	}

  // 処理が終わったので一つコードを進め(EX_Return命令をステップオーバー)、終了処理に入る
  Stack.Code++;

  if (*Stack.Code != EX_Nothing)
  {
    // 次の命令が EX_Nothing でなければ、後続の命令を評価し、RESULT_PARAM に関数の結果を格納する
    Stack.Step(Stack.Object, RESULT_PARAM);
  }
  else
  {
    // EX_Nothing なら、命令カウンタを一つ進めて(EX_Nothingをステップオーバー)終了
    Stack.Code++;
  }
}

ProcessLocalScriptFunction() はローカルでバイトコードを実行する関数です。 この関数には、ProcessScriptFunction() が整えてくれた FFrame& Stack が渡されてきます。Stack.CodeUFunction の保持するバイトコード上の位置を示すポインタで、いわゆるプログラムカウンタの役割を果たします(後述)。処理をみると、Stack.Code を進めることで命令の処理位置が進んでいき、EX_Return まで実行され続けることがわかります。

FFrame

さて、UFunction の実行にも登場しましたが、Blueprint VM の実行処理をより詳しく見ていくためには、FFrame のことを知る必要があります。

FFrame は、C / C++ など言語にも存在するスタックフレームに近い役割を果たすクラスです。スタックフレームとは、関数呼び出しのためにスタック領域に構築される、ローカル変数、リターンアドレス(呼び出し元のアドレス)、引数などの情報を持ったデータ構造です。関数が呼び出されると、その関数の命令を実行するための作業領域がメモリ上に必要ですし、自分がどこから呼ばれたのかがわからなければ関数からの return ができないので、それらの情報を扱うためのデータ構造を関数の呼び出し時に構築しているのです。

FFrame はスタックフレーム同様、関数呼び出しのたびに構築され、関数が終了すると破棄されるので、呼び出しがネストすると多段的に存在することもありますが、特定の関数呼び出しに対応する FFrame は一つであると考えてよいです。

FFrame はスタック領域に確保されるわけではありませんし、データ構造としてもスタックではありません。プログラムカウンタの役割を持つ変数や、直近の命令の結果を保持する変数などもメンバに持つので、それらはレジスタ的であるとも言えます。 下図は、FFrame の主要なメンバ変数を示したものです。典型的なスタックフレームにありそうなものを青、レジスタ的な働きをしているものを橙に塗り分けたので、参考程度に御覧ください。

典型的なスタックフレームっぽいやつ

  • UFunction* Node
    • 現在の FFrame に対応する UFunction のポインタを保持する。
  • UObject* Object
    • 現在の FFrame に対応するコンテキストオブジェクト。多くの場合、関数が属する UObject のインスタンスと考えてもよい。ローカル変数へのアクセスなどに利用される。一部の命令の実行において、暗黙的に this と同等の役割として扱われる。
  • uint8* Locals
    • 現在の FFrame に対応する UFunction のローカル変数を保持しているメモリ領域の先頭アドレス。複数のローカル変数が連続して配置されることがあるが、別途得られたローカル変数の FProperty の情報を元に先頭アドレスからのオフセットを計算し、任意のローカル変数にアクセスできるようになっている。
  • FOutParamRec* OutParams
    • ミュータブルな参照渡しなどによって、戻り値以外の出力パラメータと認識される引数がある場合、その引数は CPF_OutParm というフラグを持つようになる。OutParams は、FOutParmRec のメンバとして、最初の追加出力引数の FProperty へのポインタ、その FProperty へのデータの保存先アドレス、および次の追加出力パラメータを保持する。リンクリストになっているので、一つずつ辿っていくことで、すべての追加出力パラメータにアクセスできる。
    • 戻り値を格納すべき領域は OutParams のように FFrame のメンバとしてではなく、RESULT_DECL のように、命令を処理する関数の引数として既定のものが渡されてくる。

レジスタっぽいやつ

  • uint8* Code
    • いわゆるプログラムカウンタのような変数。実行中のバイトコードの実体は UFunction のメモリ領域などに配置されているが、 Code はそのバイトコード上の特定の位置をポイントすることで、現在実行中の命令位置を保持する。この変数の指す位置をインクリメントすることで、バイトコードの実行が進む。
  • FProperty* MostRecentProperty
    • 直近の命令でアクセスされた FProperty のアドレスを保持する。これにより、Blueprint VM は、続く命令で前の命令で得られたプロパティに対するアクセスが可能になる。
  • uint8* MostRecentPropertyAddress
    • MostRecentProperty のデータの実体が配置されたメモリ領域の先頭アドレス。 FProperty は型のリフレクション情報であり、値そのものは持たないので、FProperty の情報やメソッドを利用してこのメモリ領域から値を読み出すことで、実値へのアクセスが可能となる。
  • uint8* MostRecentPropertyContainer
    • MostRecentPropertyContainer は、直近の命令でアクセスされた FProperty コンテナのメモリ領域の先頭アドレスを保持する。処理のステップによっては、「あるメンバプロパティのアドレス位置はまだわからないが、そのプロパティを持っているオブジェクトのアドレスはわかる」といったシチュエーションがよくある。プロパティを保持するオブジェクトのことを Property Container と呼んでおり、追加で Property Container のアドレスにオフセットをかけることでメンバのアドレスに到達できる。FProperty が保持するリフレクション情報には、オブジェクト内での自身のメモリ配置のオフセット情報が含まれているが、それは相対的な値であるため、オフセットをかける対象となる絶対アドレスとして MostRecentPropertyContainer が必要となる。

FFrame による Blueprint VM 命令のステップ実行

Blueprint VM のバイトコード実行の実体は、FFrame による Code ポインタのインクリメントです。 バイトコードには命令のほか、オブジェクトのバイト表現が埋め込まれたり、値リテラルを表すバイトが埋め込まれたりします。それらを処理次第、バイト数分だけ Code を進めるのです。

処理の実行に最も多用される FFrame のメンバ関数は、以下の FFrame::Step()です。

void FFrame::Step(UObject* Context, RESULT_DECL)
{
	int32 B = *Code++;
	(GNatives[B])(Context,*this,RESULT_PARAM);
}

このメンバは、一つ先のバイトコードを読み出し、そのバイト値を関数ポインタの配列 GNatives[] のインデックスとすることで、命令コードによる命令関数の呼び出しを実現している関数です。先に命令表で示した命令コードが、ここでの変数 B に数値として読み出されるということです。

GNatives[] は以下のように定義されます。

COREUOBJECT_API FNativeFuncPtr GNatives[EX_Max];

この配列に対して、命令処理を実装した関数が、各々追加されています。以下は単純な EX_Jump 命令の実装です。

EX_Jump
void UObject::execJump( UObject* Context, FFrame& Stack, RESULT_DECL )
{
  // 現在の Stack.Code 位置から、CodeSkipSizeType のサイズのメモリを読み出し、CodeSkipSizeType として解釈して返す。
  // sizeof(CodeSkipSizeType) だけ Stack.Code が進む。 
	CodeSkipSizeType Offset = Stack.ReadCodeSkipCount();
  // 現在の Stack.Code を、`UFunction` の保持するバイトコードへの特定オフセット位置に置き換える
	Stack.Code = &Stack.Node->Script[Offset];
}
IMPLEMENT_VM_FUNCTION( EX_Jump, execJump ); // GNatives に EX_Jump の値(列挙型であり命令コードを示す)で登録

なお、 CodeSkipSizeType とはバイトコード上のオフセットを表すのに十分なサイズの数値型で、多くのプラットフォームでは uint32 です。 この処理によって、バイトコード上に示されたジャンプ位置に現在の Stack.Code を書き換え、後続する命令の内容を変更することができます。 CodeSkipSizeTypeuint32 であると仮定したときのバイトコードを図示するなら、以下のようになるでしょう。

また、もう少し複雑な例として、条件付きジャンプ EX_JumpIfNot も見てみましょう。

EX_JumpIfNot
void UObject::execJumpIfNot( UObject* Context, FFrame& Stack, RESULT_DECL )
{
	CHECK_RUNAWAY;

  // 希望されるジャンプ先のオフセット位置をバイトコードから読み出す
	CodeSkipSizeType Offset = Stack.ReadCodeSkipCount();

	// 続く命令を Step で評価する。その結果を第二引数の `Value` に受け取る。
	bool Value = 0;
	Stack.Step( Stack.Object, &Value );

  // `Value` の値によってジャンプするか否かを分岐
	if( !Value )
	{
		Stack.Code = &Stack.Node->Script[ Offset ];
	}
}
IMPLEMENT_VM_FUNCTION( EX_JumpIfNot, execJumpIfNot );

EX_JumpIfNot では、FFrame::Step() で実行された execJumpIfNot() の中で、更に FFrame::Step() を呼び出しています。 このパターンは多くの命令の実装で見られるもので、バイトコードの表現の自由度を向上させています。 EX_JumpIfNot は、Value を評価式に持つ単純な分岐命令などに利用できるものです。しかし、Value の値の決定を命令処理内部で更に FFrame::Step() することで、ジャンプするか否かの決定を、後続の任意のバイトコードの実行結果によって決定できるようになっているのです。 Value の値を決定するのはもしかすると単にバイトコード上に埋め込まれた定数かもしれませんし、複雑な処理の結果かもしれません。

続いて、MostRecentProperty などを利用する命令の例として、ローカル変数へのアクセス命令 EX_LocalVariable も見ておきます。 (例のごとく例外処理は省いています)

EX_LocalVariable
void UObject::execLocalVariable( UObject* Context, FFrame& Stack, RESULT_DECL )
{
  // バイトコードから、読み出したいプロパティを表す `FProperty` を読み出す。その分 Code は進む。
	FProperty* VarProperty = Stack.ReadProperty();
  // `Stack.Locals` が指すローカル変数メモリ領域に対して、`FProperty` のメモリ配置情報を適用し、読み出したいプロパティデータのアドレスを決定する。そのアドレスを `MostRecentProeprtyAddress` に入れておく。
  Stack.MostRecentPropertyAddress = VarProperty->ContainerPtrToValuePtr<uint8>(Stack.Locals);
  // ↑ で `MostRecentProeprtyAddress` を更新したので、そのプロパティのコンテナである `Stack.Locals` のアドレスに `MostRecentPropertyContainer` も更新しておく
  Stack.MostRecentPropertyContainer = Stack.Locals;

  if (RESULT_PARAM)
  {
    // プロパティに Getter が定義されているか
    if (VarProperty->HasGetter())
    {
      // されていたら Getter で読み出し、RESULT_PARAM に受け取る
      VarProperty->GetValue_InContainer(Stack.MostRecentPropertyContainer, RESULT_PARAM);
    }
    else
    {
      // されていなかったら、プロパティデータのアドレスからデータを RESULT_PARAM にコピーして値を読み出す
      VarProperty->CopyCompleteValueToScriptVM(RESULT_PARAM, Stack.MostRecentPropertyAddress);
    }
  }
}
IMPLEMENT_VM_FUNCTION( EX_LocalVariable, execLocalVariable );

このように、プロパティに直接アクセスする命令の中では、アクセスしたプロパティの履歴を FFrame の Recent~ 系のメンバに記録してくれるのです。これにより、FFrame を共用する後続の命令もそのプロパティにアクセス可能になるので、プロパティアクセスに頻繁に利用されます。

これらの命令が読めれば、大抵の命令の処理は読むことができます。 ScriptCore.cpp に実装がありますので、気になる命令は見てみるとよいでしょう。

Blueprint VM 命令の定義

Blueprint VM 命令表 で、定義されている命令とその役割は記載しましたが、実際にはどこにどうやって定義されているのかについてはまだ触れていませんでした。

これは、 Runtime/CoreUObject/Public/UObject/Script.h をみるとすぐにわかります。公式ドキュメントにも記載のある、 EExprToken が、Blueprint VM の命令のコードを決定しています。 ここで定義した列挙型の値を、一つ前の項でも見た命令の実装と紐づけて、 GNatives[] に入れておくことで、命令の呼び出しを実現しているようです。

Blueprint バイトコードの逆アセンブル

ここまで、実装ベースで Blueprint VM の背後を見てきました。しかし、バイトコードの役割がわかるようになったからには、実際に実行されているバイトコードを確認してみたくなるでしょう。

Unreal Engine には、コンパイルして生成されたバイトコードを人間が読みやすい形で逆アセンブルして出力する FKismetBytecodeDisassembler というクラスが定義されています。これは Blueprint コンパイラの中でも利用できるようになっていて、Engine.ini に以下を加えることで有効化できます。

[Kismet]
CompileDisplaysBinaryBackend=True

これが有効化されていると、Blueprint のコンパイル時に、生成されたバイトコードを逆アセンブルしたテキストがログ出力されるようになります。 たとえば、以下のグラフをコンパイルすると……

これが出てきます。

LogK2Compiler: [function ExecuteUbergraph_L_GameEntry]:
Label_0x0:
     $4E: Computed Jump, offset specified by expression:
         $0: Local variable of type int32 named EntryPoint. Parameter flags: (Parameter).
Label_0xA:
     $68: Call Math (stack node KismetSystemLibrary::PrintString)
       $17: EX_Self
       $1F: literal ansi string "Hello"
       $27: EX_True
       $27: EX_True
       $2F: literal struct LinearColor (serialized size: 16)
         $1E: literal float 0.000000
         $1E: literal float 0.660000
         $1E: literal float 1.000000
         $1E: literal float 1.000000
         $30: EX_EndStructConst
       $1E: literal float 2.000000
       $21: literal name None
       $16: EX_EndFunctionParms
Label_0x52:
     $6: Jump to offset 0x7D
Label_0x57:
     $45: Local Virtual Script Function named NewFunction
       $28: EX_False
       $1D: literal int32 0
       $21: literal name None
       $16: EX_EndFunctionParms
Label_0x78:
     $6: Jump to offset 0xA
Label_0x7D:
     $4: Return expression
       $B: EX_Nothing
Label_0x7F:
     $53: EX_EndOfScript
LogK2Compiler: [function ReceiveBeginPlay]:
Label_0x0:
     $46: Local Final Script Function (stack node L_GameEntry_C::ExecuteUbergraph_L_GameEntry)
       $1D: literal int32 87
       $16: EX_EndFunctionParms
Label_0xF:
     $4: Return expression
       $B: EX_Nothing
Label_0x11:
     $53: EX_EndOfScript
LogK2Compiler: [function NewFunction]:
Label_0x0:
     $68: Call Math (stack node KismetSystemLibrary::PrintString)
       $17: EX_Self
       $1F: literal ansi string "Hello"
       $27: EX_True
       $27: EX_True
       $2F: literal struct LinearColor (serialized size: 16)
         $1E: literal float 0.000000
         $1E: literal float 0.660000
         $1E: literal float 1.000000
         $1E: literal float 1.000000
         $30: EX_EndStructConst
       $1E: literal float 2.000000
       $21: literal name None
       $16: EX_EndFunctionParms
Label_0x48:
     $4: Return expression
       $B: EX_Nothing
Label_0x4A:
     $53: EX_EndOfScript

$xx のように表示されているのが Blueprint VM の命令コードであり、それに合わせて命令の名前などを併記してくれています。 また、インデントを変えることでその内部で実行されている命令を表現してくれています。リテラル値を表す命令などではその値も示してくれているので、大変読みやすいです。

ただ、実際に Config に上記の設定を加えてみると、Trace~ などのよくわからない命令が大量に入ると思います。これは Blueprint デバッガのための命令がノード単位で挿入されるからです。これを回避するには、エンジンのソースコードをいじるか、デバッガでデバッグ命令を挿入するかのフラグを上書きする必要があります。僕は面倒なので後者の手法で、FKismetFunctionContext::bCreateDebugData の値を false に上書きすることで綺麗な出力を得ています。

この知識、何に使えるの?

普段意識する必要は全く無いでしょう。しかし、記事中でも少し触れた CustomThunk などの Thunk 関数を自作するようなコードは非常に強力です。というのも、本来は Blueprint VM 側に処理が隠されてしまい、結果の引数しか受け取れないはずの C++ 実装で、実行中の Blueprint VM の FFrame に直接操作を加えることができるのです。このため、ある種のメタプログラミングのようなことが可能になります。 代表格は Wildcard ピンなどです。たまに見かける、繋いではじめて型が確定する灰色のピンがあると思いますが、あれが Wildcard ピンで、そういった特殊な機能を気軽に利用できるようになります。

また、最適化などの面でもかなり役立つと思います。Blueprint VM を見ていると、案外まだ最適化の余地がありそうな実装がちらほら見受けられます。エンジンに手を加えずとも、Blueprint VM に様々な命令を実行させることはプロジェクトからも可能ですから、「さいきょうの最適化ノード」を構築することもできるでしょう。

今回の記事では間に合いませんでしたが、バイトコードを生成しているコンパイラに対して手を加えることもできます。Blueprint Compiler には CompilerExtension というコンパイラ拡張用のAPIが存在しており、コンパイラ側から実行したいバイトコードを変更することができます。K2_Node を深いレベルから実装する場合、FNodeHandlingFunctor::Compile() という、ノードの動作を Blueprint Compiler が処理できるステートメントに記述し直す処理を実装する必要があります。この場合にも Blueprint VM の知識は大いに役立つでしょう。

まとめ

Blueprint VM は、Unreal Engine でのコンテンツ制作に特化したドメイン固有言語であるということが、実装からもよくわかりました。 高速化のためにバイトコードに変換されていますが、C++ との連携機能は柔軟かつ強力であり、我々が遊ぶ余地も沢山ありそうです。 みなさんもどんどん 「Blueprint を書いて」いきましょう!

おわりに

コンパイラまで書こうと思ったけど間に合わなかったよ!!!

ところで、そろそろ公式に動きがありそうな新スクリプト言語 Verse が気になります。現時点の情報として、エンジンをまたいで利用可能なスクリプト言語であるとのことなので、Blueprint VM とは全く別の実行基盤が搭載されると考えたほうがよさそうです。 一応コンテンツ制作向けということで DSL とも言えるのかもしれませんが、汎化の具合によっては一つの汎用言語として、Blueprint VM の実装思想とはかなり異なるものなのかもしれません。待ち遠しいですね。

明日は @dgtanake さんの『UEのPCゲーム対応について』です。楽しみですね。  

share