ホーム / ブログ / エンジン
公開日: 2026年5月24日 約 9 分 エンジン
Unreal EngineでBlueprintとC++をどう使い分けるか:実測7msで見えた性能の境界線

Unreal EngineでBlueprintとC++をどう使い分けるか:実測7msで見えた性能の境界線

Blueprint VMのコストはほとんどのシーンでは見えないが、毎ティック数万ノードになった途端に7msを消費した。実測値をもとに、どこまでをC++に置き、どこからをBlueprintに残すのか、UPROPERTYの公開範囲やチーム拡大後のマージ問題まで含めて、ハイブリッド構成の境界線を具体的な数字で説明する。

1ティックで4万1000ノードが走ったとき

2023年後半に関わっていたタワーディフェンスの案件で、ウェーブ14に入った途端フレーム時間が11.2msから19.4msまで伸びた。エディタ上ではどれも許容範囲に見えていたが、パッケージ化したDevelopment buildでは差がはっきり出ていた。Unreal Insightsの一番上に並んでいた行はBlueprintTimeで、それだけで7.1msを食っていた。

原因はBP_TowerBaseの中のEvent Tickだった。どのタワーもForEachLoopで220体の敵を全部たどり、距離の二乗を計算して一番近い相手を選んでいた。タワー24基に敵220体、さらにノードごとの数回の演算を掛け合わせると、1フレームあたりおよそ4万1000ノードの実行になる。

問題は「Blueprintが遅い」ことではなかった。O(n·m)の探索を毎フレーム仮想マシンの中で回していたことが問題だった。そのコードをC++に移せばコストは確かに下がるが、本当の誤りはアルゴリズムの側にあり、Unreal Blueprint vs C++の議論ではこの二つが絶えず混同されている。

当時チームは3人で、誰一人としてBlueprint側をプロファイルしていなかった。フレーム時間が崩れていくなかで、まずdraw callを疑い、次に影の設定、その次にテクスチャ解像度を見た。2日を失ったあと、Insightsを開いて正しい行を読むだけで済んだ。この記事の残りは、その2日を二度と繰り返さないために書き出したルールである。

Blueprintの性能が計測可能になる地点

数字を出すために素朴なテストをした。中身が空の関数を10万回呼ぶだけである。C++側の合計は0.4ms、まったく同じ関数がBlueprintでは6.8msかかった。呼び出し1回あたり、およそ68nsのオーバーヘッドという計算になる。

68nsは何でもない値に見えるし、たいていの場面では実際にそのとおりだ。200ノードのドア開閉フローや、インベントリ画面の裏にあるロジック程度なら、コストはノイズの下に埋もれたままになる。1フレームあたりおおよそ2000ノードを下回る範囲では、私が取ったどの計測でも合計の影響が0.15msを超えたことはない。

それがノイズでなくなる地点ははっきりしている。1フレームあたり数万ノードだ。私が使っているおおまかなしきい値はこうだ——毎フレーム走るBlueprintで、しかも中にループがあるなら、そのロジックはもうC++の候補である。単発のイベントで起動するフローなら、VMのオーバーヘッドは議論する価値すらない。

計測そのものの罠もある。PIEではデバッグ用の計装が入るため、Blueprintの呼び出しは実際より高価に見える。エディタで出た数字だけを見て判断すると方向を誤る。パッケージ化したDevelopment buildをstat gameとInsightsで確認するまで、私は何一つC++に移さない。

ハイブリッドな進め方:中核はC++、調整はBlueprint

ターゲット選択、ダメージ計算、クールダウンの管理、そしてステートマシンは、C++としてAOFKTowerBaseの中へ移した。ターゲット探索はもう毎フレームは走らず、0.2秒のタイマーで空間グリッドの上を走る。Blueprintに残したのは、エフェクトのタイミング、音のトリガー、UIのフィードバック、そしてデザイナーが調整し続ける数値である。

フレーム時間は19.4msから11.8msになり、BlueprintTimeは7.1msから0.9msまで落ちた。そのすべてがVM由来ではない。およそ5msはアルゴリズムの変更によるもので、ネイティブコードへの移行によるものは2.6msである。この内訳を示さずに「C++で40%速くなった」と言うのは誠実ではない。

却下した案が二つある。全部をC++にするのは技術的には最速だったが、ダメージ値をひとつ試すだけでデザイナーは90秒のコンパイルとエディタの再起動に付き合わされる。1日に40回試す人間にとって、それは受け入れられない。全部をBlueprintに置いたままtickだけ切る案は、コストの原因になっていたループを取り除かないので何の効果もなかった。

線を引くときに私が投げる問いは単純だ。この値を変えるのにコンパイルが要るか。要るうえに、デザイナーが週に何度も変えるのなら、その値はBlueprintかデータアセットに置かれるべきだ。タワーのバランス表はUDataAssetとして持っている。C++が読み、デザイナーがエディタで編集し、誰もビルドを待たない。

AOFKCharacter.h
#pragma once #include "CoreMinimal.h" #include "GameFramework/Character.h" #include "AOFKCharacter.generated.h" UCLASS() class OFKGAME_API AOFKCharacter : public ACharacter { GENERATED_BODY() public: // Designer-tunable value, read-only in Blueprint so the rule stays in C++. UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Combat", meta = (ClampMin = "0.05", UIMax = "2.0")) float DashCooldown = 0.35f; UFUNCTION(BlueprintCallable, Category = "Combat") bool CanDash() const; protected: // C++ decides when it fires; Blueprint decides how it looks and sounds. UFUNCTION(BlueprintImplementableEvent, Category = "Combat") void OnDashStarted(float Cooldown); private: float LastDashTime = -1000.f; };
cppAOFKCharacter.h

UPROPERTYで正しい面だけを公開する

ハイブリッド構成の質は、C++がBlueprintに何を公開するかで正確に決まる。調整用の数値はUPROPERTY(EditAnywhere, BlueprintReadOnly)として出す。デザイナーはエディタで値を変えられるが、実行時に書き込むことはできない。meta = (ClampMin, UIMax)を添えるだけで、「うっかり0を入れた」たぐいのバグの半分が、生まれる前に消える。

関数側では、三つの指定子の違いが効いてくる。BlueprintCallableはBlueprintがC++に問いを投げること、BlueprintImplementableEventはC++がBlueprintに「今これが起きた、見た目はそちらで面倒を見てくれ」と伝えること、BlueprintNativeEventはC++側に既定の実装を置き、必要ならBlueprintがそれを上書きできるようにすることだ。ルールは短い。いつ起きるかはC++が決め、どう見えるかはBlueprintが決める。

いちばんよく見かける失敗は、あらゆるフィールドにBlueprintReadWriteを押してしまうことだ。いったん開けば、デザイナーは状態変数へ書き始め、C++で守っていた不変条件が静かに壊れていく。私たちの場合、それは弾数カウンタが負の値になるという形で表に出た。二つ目の罠はリネームである。UPROPERTYの名前を変えるとBlueprint側の参照が黙って切れるので、Core Redirectsのエントリを書かずにフィールド名を変えることは絶対にしない。

これにはコンパイル時間という側面もある。ヘッダのUPROPERTYにひとつ触れるだけで、そのヘッダをインクルードしているものすべてが再ビルドされ、そこそこ良いマシンでも4分に達していた。頻繁に変わる設定を小さな別の構造体にまとめたことで、デザイナーの反復が速くなり、1日にかける完全な再ビルドの回数も目に見えて減った。

nativizationが廃止されて何が変わったか

4.26のころ、Blueprint nativizationに頼っているチームがいくつかあり、私たちも一時期は試していた。重いシーンでの実測ゲインはおよそ1.6ms、その代わりにパッケージングが18分長くなり、nativizeしたビルドでしか出ないバグを三つ踏んだ。UE5はこの選択肢を丸ごと取り除いた。

実務上の帰結は、終盤になって「コンパイラが何とかしてくれる」という逃げ道がもう存在しない、ということだ。ホットパスをC++に置くのはもはや最適化の一工程ではなく、プロジェクトの最初に下す設計上の判断である。UE5 C++を後から貼りつけるパッチのように扱うことが、制作の途中でチームを捕まえる。

良い面もある。nativizationがあったころ、人々はBlueprintに重いロジックを書き、「あとでnativizeすればいい」と計画していたが、その「あと」は決して来なかった。選択肢が消えたことで、最初に境界を引くことが必須になり、結果として時間とともにより整ったコードベースが残った。

nativizationの代わりに私たちが置いたのは計測である。リリースのたびに、パッケージ化したビルドからInsightsのトレースを取り、BlueprintTimeが1msを超えたら、どのBlueprintに責任があるかを追う。この確認は15分で終わる。この1年で3回、出荷される前に深刻な劣化を捕まえた。

チームが大きくなってからのマージ衝突

4人のうちは、Blueprintがバイナリアセットであることは問題にならなかった。11人になると、週に2、3回は同じ.uassetを2人が触るようになり、そして.uassetはマージできない。負けた側が自分の作業をやり直すことになる。あるスプリントでその形で失った時間を、私たちはおよそ2日と見積もった。

Perforceで強制チェックアウトと排他ロックを設定した。衝突は止まり、代わりに待ちが入ってきた。あるBlueprintがロックされている間、2人目は待つか、別の作業に移るかになる。C++にはその問題がない。テキストは行単位でマージされ、pull requestの中で実際に読める。一方、Blueprintのレビューは実際のところ、スクリーンショットを送り合うことに等しい。

今のUnreal Engine開発では、四つのルールを適用している。毎フレーム走るロジックはBlueprintに残さない。そして新しく作るBlueprintではtickを既定でオフにする。あるBlueprintが150ノードを超えたら、その一部をC++へ出す。さらに、ひとつのスプリントの中で2人が同じBlueprintを編集する必要があるなら、そのロジックはすでにC++のものである。

これらのルールは、性能と同じくらいチームのスループットの話でもある。Blueprintはデザイナーの素早い反復のために、C++はシステムの背骨のために使い、その線は最初の1行を書く前に引いておく。あとから移すのは常により高くつく。私たちが払った請求書は、3週間のリファクタリングだった。

← すべての記事