はじめに
こんにちは。内野 航希と申します。
これまでUnityのみでゲーム制作をしていた私ですが、今年のゲーム制作で、初めてUnreal Engine 5(以下、UE5)を使用しました。
しかし、Unityの感覚に引きずられ、UE5の学習が難しいと感じる場面が何度もありました。
そこで、私が学習してつまづいた点やUnityと似た点をまとめ、Unityを使ったことはあるけれど、UE5は使ったことがない人へ向けた資料を作りました。
(私自身もUE5を使い始めて間もなく、こういった技術系サイトへ初めての投稿のため、ミスや間違いなどが多くあると思います。その際はご指摘いただければ幸いです。)
UnityでできたことをUE5でできるようにする
私がUE5の学習をするのが遅くなった要因として、「UnityでできるのだからわざわざUE5に移行しなくても良い」という感覚があったからだと考えています。
その意思と感覚のギャップを埋めるため、UE5が触りやすいゲームエンジンであるという印象になるようUnityの名称を交えて紹介したいと思います。
UnityとUE5の名称比較
まずはUnityの名称でUE5の機能を紹介します。
ゲームエンジンから見る名称の違い
UE5名称 | Unity名称 |
---|---|
コンテンツドロワー | Project |
アウトライナー | Hierarchy |
詳細 | Inspector |
レベル | シーン |
Contentフォルダ | Assetフォルダ |
スクリプト面から見る名称の違い
UE5名称 | Unity名称 |
---|---|
BeginPlay | Start |
Tick | Update |
OverlapAllDynamic | RigidbodyのisTrigger |
Location | position |
Actor | GameObjectの多機能バージョン |
Gamemode | Singletonのスクリプト |
GameInstance | SingletonのDon't Destroy On Loadスクリプト |
ブループリントとC++の違いについて
ではゲームを作るうえで必要不可欠なスクリプト作成はどのようにするかについてです。
まずUE5はブループリントによるノードベースのプログラミングと、C++によるコードを書くプログラミングの二種類があります。
これらについてざっくりと特徴をまとめます。
-
ブループリント
- 処理の流れを追いやすい
- ノードに機能がまとまっているため、人ごとのブレが比較的少ない
- 入力と出力がピンによって管理されているため、変数の型を間違えない、Castできる場合は補完してくれる
- 簡単に操作できるため実装が速い
-
C++
- 既にコードをかける人にとってはわかりやすい
- ノードの機能に縛られず、自由に計算、処理させられる
- エディターやAIの恩恵を受けやすい
それぞれ一長一短ではあると思います。
Unityで既にプログラミング経験があり不自由なく開発できるのであれば、C++を主に使って開発するのは思っているほど難しくはないと思います。ただし、初めてという方はブループリント開発のみから始めてみるほうが敷居は確実に低いです。
実際にスクリプトを作成する
スクリプティングの感覚の違い(主観)
ここで先んじて、Unityとはスクリプティングの感覚が違うように感じるため紹介します。
UnityはGameObjectという基幹に対し肉付けをするようにコンポーネントをアタッチしていたと思いますが、UEでは複雑な振る舞いができるオブジェクトに動き方を指定する、といった感覚で作るのが良さそうです。
ブループリントで作成する
ブループリントの作成
ではそのブループリントはどのように作成するのかについてです。Contentフォルダ内で右クリック→ブループリント クラスをクリック
すると、何を作成するか聞かれると思います。これは何を作るかによっても変わります。
- Actor:動作を制御することができる基本的なオブジェクト
- Pawn:Actorの中でもプレイヤーやAIが制御するオブジェクト
- Character:Pawnの中でもモーションや衝突判定をするもの
- GameModeBase:レベルごとのルールやUIを設定する
などをよく扱うと思います。
どれかを選択すると、その機能を継承したブループリントを作成できます。
次にこのブループリントをダブルクリックでエディター画面へ移動します。タブのイベントグラフがブループリントの変更画面です。
変数や関数は画像左側のマイブループリントと書いてあるタブから作成できます。
変数について
変数の隣にある+ボタンを押すと作成されます。(画像白枠部分)
- VariableName となっている部分は変数名です。右クリックで名前を編集できます(画像ピンク部分)
- Integerとある部分は変数の型です(画像黄色部分)
また変数の型マークを右クリック、もしくは詳細タブにある変数の型、右側のボタンをクリックで、リストやマップなどに変更が可能です。
配列の型はActorなど一番上には出てこないものもあるため、検索する必要があります
ノードの作成
作成した変数は、マイブループリントにある変数をイベントグラフ内へドラッグアンドドロップすることで作成できます。
ドラッグアンドドロップをする際にAltを押していればでSet、ctrlを押していればでGetを一発で作成できます。
計算させるノードの作成については、イベントグラフ内で右クリックを押し、ノードの名前を入力して目当てのノードをクリックすることで作成できます。+と入力したらAddノード(加算)、ifと入力したらBranchノード(ifと同様)が作成されたりと、その通りの名前を入れなくても用意してくれるものもあります。
私がよく使うノードも一緒にまとめます。
ノード名 | 処理 |
---|---|
lerp | 線形補完 |
Ease | 線形補完の緩急を指定できる |
Branch | if文 |
Sequence | 二つ以上の処理を見やすくする際に使用。 Output1,Output2と順次処理されるため、右に長くなる現象を抑えられる |
Print String | 実行時、ゲーム画面左上にデバッグ出力を表示させられる |
Get Array Length | 配列の要素数を取得 |
Get Actor Transform | 指定ActorのTransformを取得 |
Spawn Actor of Class | 指定クラスの生成(Instantiate) |
Add Timeline | タイムラインの作成 |
ノードを作成出来たら、ピンをつなぎましょう。
白枠から繋ぎたいノードへドラッグアンドドロップすることで、ノード同士を接続できます。
また、Altクリックすることでピンを外すことができます。
OnTriggerやOnCollisionの作り方
まず、当たり判定を作成する必要があります。
左上コンポーネントタブの下にある”+追加”ボタンを押します。追加できるものがざっと表示されるので、当たり判定があるもの(キューブや球、もしくはBox Collisionなど当たり判定のみのもの、もしくはStatic Mesh(UnityのMesh Colliderのようなもの)を作成してください。
OnCollisionを作成する場合
右側詳細タブの下のほうにある、イベントの中にあるOn Component Hitの隣にある+ボタンを押してください。イベントの開始地点がイベントグラフに作成されます。
そこからノードを伸ばして実装してください。
OnTriggerを作成する場合
右側詳細タブのコリジョン→コリジョンプリセットを選択してください。
ここをOverlapAllDynamicへ変更してください。
次に詳細タブ下のほうにある、イベントの中にあるOn Component Begin Overlapの隣にある+ボタンを押してください。
イベントの開始地点がイベントグラフに作成されます。
そこからノードを伸ばして実装してください。
なお、On Component Begin OverlapはUnityにおけるOnTriggerEnter、On Component End OverlapはUnityにおけるOnTriggerExitです。
作業が終了したら、左上のコンパイルを押します。成功なら緑、エラーなら赤のアイコンが表示されます。
コンパイルに成功したら作成完了です。
デバッグについて
デバッグをする際、ブレークポイントを設定することができます。ブレークポイントは処理がノードに到達した場合、ゲームエンジンのシミュレーションを一時停止してくれる機能です。
ノードを右クリック→ブレークポイントを作成
をすることでノード右上に赤い円マークが表示され、ブレークポイントを作成することができます。
また、PrintStringノードで出力を確認するのもよいと思います。
C++で作成する
C++スクリプトの作成
C++で作成する方法です。
C++を使用する場合、プロジェクト作成時にC++を選択して作成しておく必要があります。
選択できていたら、メニューバーからTools→New C++ Classを選択し、Add C++ Classでブループリントと同じような親クラス選択画面が表示されます。
Nextを押して名前やパスを指定し、Create Classをクリックすることで、VisualStudioが立ち上がると思います。
ヘッダーファイル(.hのほう)へ外部に公開する変数を書き、CppファイルのほうにStartやFixedUpdate、関数などを書きます。
変数や関数を書く位置に関するサンプルコード
MyActor.h
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyActor.generated.h"
UCLASS()
class TEST1_API AMyActor : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
AMyActor();
//こっちに初期化用変数などを書くのが適しているらしい
//public変数の宣言
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "hoge")
int32 PublicVariableHoge;
//Category確認用
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "piyo")
int32 PublicVariablePiyo;
//public関数の宣言
UFUNCTION(BlueprintCallable,Category = "hoge")
void PublicFunction();
protected:
//protected変数の宣言
float ProtectedVariable = 10;
//virtual関数の宣言
virtual void VirtualFunction();
// Called when the game starts or when spawned
virtual void BeginPlay() override;
private:
//private変数の宣言
int32 PrivateValue;
//private関数の宣言
void PrivateFunction();
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
//こっちに主要な関数などを置くのが適しているらしい
//public変数の宣言
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "hoge")
int32 PublicVariableHogeHoge;
//Category確認用
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "piyo")
int32 PublicVariablePiyoPiyo;
};
MyActor.Cpp
// Fill out your copyright notice in the Description page of Project Settings.
#include "MyActor.h"
// Sets default values
AMyActor::AMyActor()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;
//コンストラクタの処理。
//ゲーム外を含めた初期設定処理はここに書く必要あり。
}
// Called when the game starts or when spawned
void AMyActor::BeginPlay()
{
Super::BeginPlay();
//Start的な処理順の実装。
//ローカル変数の宣言
int32 localVariable = 100;
}
// Called every frame
void AMyActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
//Update的な処理順の具体的な実装
}
void AMyActor::PublicFunction()
{
//具体的な実装
}
void AMyActor::PrivateFunction()
{
//具体的な実装
}
void AMyActor::VirtualFunction()
{
//具体的な実装
}
また、OnComponentBeginOnverlapについてもサンプルコードを掲示します。
OnComponentBeginOverlapのサンプルコード
ChatGPT生成、動作検証済み(ThirdParsonテンプレートによる接触確認)
HitActor.h
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "HitActor.generated.h"
UCLASS()
class MYPROJECT3_API AHitActor : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
AHitActor();
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Collision")
class USphereComponent* CollisionComponent;
UFUNCTION()
void OnOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex,
bool bFromSweep, const FHitResult& SweepResult);
};
HitActor.cpp
// Fill out your copyright notice in the Description page of Project Settings.
#include "HitActor.h"
#include "Components/SphereComponent.h"
#include "Kismet/GameplayStatics.h"
// Sets default values
AHitActor::AHitActor()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;
CollisionComponent = CreateDefaultSubobject<USphereComponent>(TEXT("CollisionComponent"));
RootComponent = CollisionComponent;
// Collision設定
CollisionComponent->SetCollisionProfileName(TEXT("OverlapAllDynamic"));
CollisionComponent->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
CollisionComponent->SetCollisionObjectType(ECC_WorldDynamic);
CollisionComponent->SetCollisionResponseToAllChannels(ECR_Ignore);
CollisionComponent->SetCollisionResponseToChannel(ECC_Pawn, ECR_Overlap);
CollisionComponent->OnComponentBeginOverlap.AddDynamic(this, &AHitActor::OnOverlapBegin);
}
// Called when the game starts or when spawned
void AHitActor::BeginPlay()
{
Super::BeginPlay();
}
// Called every frame
void AHitActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
void AHitActor::OnOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex,
bool bFromSweep, const FHitResult& SweepResult)
{
if (OtherActor && OtherActor != this)
{
UE_LOG(LogTemp, Warning, TEXT("Overlap with %s"), *OtherActor->GetName());
}
}
編集が終了したらファイルをセーブし、ゲームエンジン側に戻ります。
画面右下にある崩れたルービックキューブみたいなマークをクリックするとコンパイルが始まります。(これを押すまではコンパイルされないので気を付けてください。)
コンパイルが終了したら、結果が右下に表示されます。成功の場合は緑のチェックマーク、失敗の場合は赤の三角マークです。
成功したらお疲れ様です。失敗したらVisualStudioへ戻ってエラーを取り除いてください。
デバッグについて
VisualStudioのブレークポイントが使えるようです。
ビルド構成をDebugEditorもしくはDebugGameに変更し、設定したい地点で右クリック→Breakpoint→Insert Breakpointをクリックすることで、行番号の左に赤い丸が付きます。
最後にVisualStudioにてF5を押すことでデバッグモードの起動をすることができます。
もし通過した場合はゲームエンジンが一時停止し、VisualStudio側で変数の中身など確認できるようです。
再開する際はVisualStudio上部のDebug→Continueを押すことで再開することができます。
また、ブレークポイントとは別に、UE_LOGを使用することでUE5の出力ログ側にデータを出力させることができます。
いちいち止めたくない場合はこちらも利用すると良いと思います。
また、ブループリントとC++を合わせて使うこともできます。
- ブループリントのアクターに対しC++コンポーネントをつける
- ブループリントのカスタムノードをC++で作る
- C++アクターを継承したブループリントアクターを作成する
またその逆でブループリントをC++で継承するなど、それぞれを組み合わせて使用できるようです。
ブループリントをC++が継承する場合は継承部分に直接書き込み、逆の場合はコンテンツドロワーからC++クラスを右クリック、(Class名)に基づくブループリントクラスを作成します をクリックすることで作成できます。
注意事項
C#でスクリプティングするUnityと違い、UE5ではガベージコレクションはゲームエンジンがサポートするUObjectベースの型のみとなっています。
少量であれば問題ないですが、大量の生成、破棄を行うとパフォーマンスに影響を与える場合があります。
UE5ゲームエンジンがサポートしているガベージコレクションは、UObjectであり参照がなくなった際、次回のガベージコレクション時に破棄されます。もしタイミングを管理する場合は、明示的な破棄が可能なためそちらの利用を検討してください。
また、参照が残っている場合はガベージコレクションが動かないため気をつけてください。
UE5の特徴
これまで、UE5とUnityの共通点、またスクリプトの作り方について説明しました。
しかし、それでは「使い慣れたUnityのほうが快適じゃん!UEやーめた!」となる方がいるかもしれないので、UE5で実装されている便利な機能を紹介します。
-
ApplyDamage、AnyDamage
ダメージを与える、受ける処理がゲームエンジン側で最初から用意されています。UE5を使用しているときはこれを利用する!と共通認識で使うことができるため、重複した機能実装をされる恐れがなく、ダメージ処理を一元化できます。 -
GameMode、GameInstance
GameModeがSingletonのシーンマネージャー、GameInstanceがDon't Destroy On LoadにしたSingletonのゲーム全体マネージャーのような扱い方のできるものです。
GameModeはレベルごとに一つ、GameInstanceはゲームに一つのみしか読み込む設定をすることができないため、機能が集まりやすく神クラスになりやすいデメリットこそありますが、基底クラスをOverrideするだけで作成できる、設定も簡単など、扱いやすいのが特徴です。
- テンプレートの豊富さ
一人称視点や三人称視点、ARやVRなどのゲーム用テンプレートはもちろん、映画や建築、シミュレーションなど多岐にわたるテンプレートが最初から使用できます。
特にアクションゲームなどは動く人型キャラクターが用意されている分、モック作成で特に速度をもって開発をすることができます。
ゲームエンジンの選び方について
これまでUE5を褒めていましたが、当然Unityが勝っている部分もあります。
となると、ゲームエンジンがどちらを選べば良いか分からなくなることもあると思います。
そんなゲームエンジンの選び方について私なりの考えをお話しできたらと思います。
処理速度
スクリプトがどれくらいの速度で処理されるかによって、メインの遊びとなるゲームシステム部分がどれだけ複雑化できるかが決まると思います。
処理速度については、一番気になった生成処理について調査しました。
検証内容
使用PCは自身で所有するデスクトップPC、
- CPU:Intel Core i5-9400F(オーバークロック無し)
- メモリ:48GB
- GPU:RTX3070Ti
- OS:Windows10
実際に使用した計測用スクリプト
Unity
InstantiateTest.cs
using UnityEngine;
using System;
public class InstantiateTest : MonoBehaviour
{
public int numberOfObjects = 10;
public GameObject prefab;
//タイマー部分
private DateTime startTime;
private DateTime endTime;
private float timer = 0;
private int loopCount = 0;
[SerializeField] private CSVWrite csvWrite;
// Update is called once per frame
void FixedUpdate()
{
timer += Time.deltaTime;
if(timer > 1f && loopCount < 100)
{
startTime = DateTime.Now;
for(int i = 0; i < numberOfObjects; i++)
{
GameObject cube = Instantiate(prefab,new Vector3(0,0,0),Quaternion.identity);
Destroy(cube);
}
endTime = DateTime.Now;
Debug.Log(endTime - startTime);
loopCount++;
//CSVに書き込む
if(csvWrite != null)csvWrite.WriteCSV((endTime - startTime).ToString());
}
if(loopCount >= 100)
{
Debug.Log("Finish");
if(csvWrite != null)csvWrite.streamWriter.Close();
}
}
}
CSVWrite.cs(計測結果出力用)
using UnityEngine;
using System.IO;
public class CSVWrite : MonoBehaviour
{
public StreamWriter streamWriter;
// Start is called once before the first execution of Update after the MonoBehaviour is created
void Start()
{
streamWriter = new StreamWriter("Assets/InstantiateTest.csv",true);
}
public void WriteCSV(string data)
{
streamWriter.WriteLine(data);
}
}
ブループリント
C++
Cpp_Instantiate.h
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Cpp_Instantiate.generated.h"
UCLASS()
class RESEARCHPROJ_UE5CPP_API ACpp_Instantiate : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
ACpp_Instantiate();
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
UWorld* World;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
UPROPERTY(EditAnywhere,Category = "Spawn Settings")
TSubclassOf<AActor> ActorToSpawn;
UPROPERTY(EditAnywhere, Category = "Spawn Settings")
int32 NumberOfActorsToSpawn;
UPROPERTY(EditAnywhere, Category = "Spawn Settings")
int32 LoopCount;
UPROPERTY(EditAnywhere, Category = "Spawn Settings")
float TimeInterval = 3.0f;
UPROPERTY(EditAnywhere, Category = "Spawn Settings")
float timer = 0.0f;
UPROPERTY(EditAnywhere, Category = "Spawn Settings")
int32 LoopCounter;
};
Cpp_Instantiate.cpp
// Fill out your copyright notice in the Description page of Project Settings.
#include "Cpp_Instantiate.h"
#include "Kismet/KismetSystemLibrary.h"
#include "Engine/World.h"
#include "GameFramework/Actor.h"
#include "UObject/ConstructorHelpers.h"
#include "Kismet/KismetMathLibrary.h"
#include "Misc/DateTime.h"
ACpp_Instantiate::ACpp_Instantiate()
{
PrimaryActorTick.bCanEverTick = true;
NumberOfActorsToSpawn = 1;
LoopCounter = 0;
ActorToSpawn = nullptr;
}
// Called when the game starts or when spawned
void ACpp_Instantiate::BeginPlay()
{
Super::BeginPlay();
World = GetWorld();
if (!World)
{
UE_LOG(LogTemp, Warning, TEXT("World is null"));
return;
}
if (!ActorToSpawn)
{
UE_LOG(LogTemp, Warning, TEXT("Actor is not set"));
return;
}
}
// Called every frame
void ACpp_Instantiate::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
timer += DeltaTime;
if (timer >= TimeInterval)
{
if (LoopCounter > LoopCount)
{
return;
}
LoopCounter++;
FDateTime startTime = FDateTime::Now();
for (int32 i = 0; i < NumberOfActorsToSpawn; i++)
{
FTransform SpawnTransform(FRotator::ZeroRotator, FVector::ZeroVector);
AActor* SpawnActor = World->SpawnActor<AActor>(ActorToSpawn,SpawnTransform);
if (SpawnActor)
{
SpawnActor->Destroy();
}
}
FDateTime endTime = FDateTime::Now();
GEngine->ForceGarbageCollection(true);//メモリ改善のためのコード
FTimespan DateTimeSpan = endTime - startTime;
UE_LOG(LogTemp, Log, TEXT("%lf"), DateTimeSpan.GetTotalSeconds());
timer = 0.0f;
}
}
実験方法
生成→削除をX回するスクリプトを100回繰り返し、その処理速度を計測しました。
桁数を揃えるために、出力ミリ秒の小数点第二位を四捨五入し、さらに1000倍して整数化しました。
実験結果
Unity Instantiate 100個×100回
Unity Instantiate 1000個×100回
Unity Instantiate 10000個×100回
UE5 ブループリント Spawn Actor of Class 100個×100回
UE5 Spawn Actor of Class 1000個×100回
UE5 Spawn Actor of Class 10000個×100回
UE5 C++ Spawn Actor 100個×100回
UE5 C++ Spawn Actor 1000個×100回
UE5 C++ Spawn Actor 10000個×100回
UE5では、回数を追うごとに効率が悪くなる結果でした。
原因を調べるためにタスクマネージャーを見たところ、メモリが大量に確保された形跡が。
どうやら、このスクリプトの書き方に問題があったのか、はたまたタイミングが合わなかったのか。ガベージコレクションが動かなかったことによる動作の遅れがあったようです。
対策したスクリプト(生成→破壊→ガベージコレクションを明示的に動かす)による結果がこちらです。
ブループリント 10000回 ガベージコレクション有
(ガベージコレクション処理時間は含めていません。)
C++ 10000回 ガベージコレクション有
(ガベージコレクション処理時間は含めていません。)
先ほどのように回数に比例して遅くなるといったことは起きませんでしたので、遅くなった原因はメモリと見て良いでしょう。
とはいえ、今回はごく短い時間で千、万という単位のActorを一気に生成したのが問題だったため、通常はガベージコレクションで破棄してくれると思います。
しかしUnityと同様に書いてしまうとこういったゲームエンジンや言語の特性による問題にぶつかる可能性が高いので、知識として知っておくだけでも良いでしょう。
また、大量のエネミーを生成するなどして重くなった際は、このことを考慮する必要がありそうです。
結果的にはUnityが軽量でした。ActorがGameObjectに比べ多機能であることが理由なのか、ライトなどゲームエンジン上の処理が理由なのかは定かではありませんが、少なくとも大量生成の速度においてはUnityに分があるようです。
グラフィック
世界観や見せたい絵、体験のためにも、ここは欠かせないと思います。
UE5においては設定しなくても美麗なグラフィックが出るのはハードルの面ではメリットでしょう。しかし裏を返せば大きく変えていないゲームは似通った見た目になりやすいため注意が必要です。
その点Unityは設定していないときこそUnityっぽさがありますが、必ず変えるべきものと認識していれば、そこに対する思考が制限されにくいとも言えます。
シェーダーにこだわれる人がいる、こだわりたい世界観や絵、空気感がすでに固まっている場合はUnity、そうでない場合はとりあえず簡単に美しく見えるUE5、と選ぶことができるかもしれません。
おわりに
いかがでしたでしょうか。この記事がUE5への一歩を踏み出せないUnityユーザーの一助となりましたら幸いです。