
Unreal Engine (UE) Advent Calendar 2024 「Unreal 3DS」
Unreal Engine (UE) Advent Calendar 2024
Unreal 3DSの作成
毎年、Unreal Engine (UE) Advent Calendarの時期になると「なんかネットワークを使って面白いこと」をやりたくなるのですが、今回はPCで動いている画面とは別に、ブラウザで別視点の画面を動かしてみて、更に立体視に対応させてみることを思いついたので、その仕組みの制作方法をご紹介致します。
PCとは別画面に同一ゲームのコンテンツを表示することで、Nintendo DSをマネしてUnreal DSという名前にしてみましたが、立体視機能も追加した為、最終的には“Unreal 3DS”という名前で公開させて頂きます。
最終的に完成したものはこちら
アプリのダウンロードはこちら
(ThirdPerson起動後にMキーを押すと9001ポートで3DSサーバが起動しますので、ブラウザで“http://起動したPCのIPアドレス:9001/mjpeg"にアクセスしてみてください)
使用した技術の解説
映像のストリーミング再生について
SceneCapture2Dの映像をブラウザへストリーミングする技術については、有名なものはWebRTC・HLS等がありますが、今回はブラウザ単体で、環境構築も必要ないという理由でMotionJPEGという古来から伝わる伝統的なストリーミング方法を採用しています。仕組みはとても単純で、Webサーバにクライアントからの接続が来たら1フレーム目をJPEG画像返却時にMotionJPEGであることをブラウザに伝えたら、その後はパラパラ漫画のようにJPEG画像をクライアントに送り付けるだけです。
余談ですが、最近のブラウザは賢いのでMotionJPEGと言いながらPNG画像でもストリーミングできてしまいます。
UnrealEngineにHTTPサーバを組み込む
UnrealEngineにはHTTPServerというモジュールが入っており、Unreal C++のBuild.csで有効化するだけでHTTPサーバを利用することができます。しかし、色々試してみたのですがMotionJPEGの実装ができなかった為、C++単体で簡易的なHTTPServerを実装することができる「Civetweb」というライブラリをUnreal C++に取り込んで使用しています。(詳しくは環境構築で解説します)
立体視について
10年位前にVRヘッドセットの先駆けとしてGoogleが発表した「Google Cardboard」をご存知でしょうか?段ボールでヘッドセットを作成し、スマホをセッティングするだけでVRヘッドセットになるということで、世の技術者たちが段ボールとレンズを買い漁ったことで有名な「Google Cardboard」ですが、Meta QuestやApple Vision Proが登場したことで、その存在は忘れ去られようとしていました。
しかし、高価なハードウェアを準備せずとも気軽にVRが楽しめる仕組みとして、今でも活用できる機会があっても良いと思い、今回採用してみることにしました。
映像については、キャラクターの右目・左目それぞれにSceneCapture2Dカメラを配置して、それぞれのキャプチャ画像を左右に並べるという方法を取っています。
環境構築方法
1.Unreal Engine でThirdPersonテンプレートを作成(検証環境は5.5.1です)
親の顔より見たThirdPersonテンプレートで新規プロジェクトを作成してください。
2.C++クラスを追加
HTTPサーバ処理を実装する為のクラスを定義します。親クラスは「なし」で名前を“MyMotionJpegServer”としました。(ソースコードは後述)

このクラスでHTTPサーバの起動・停止・SceneCapture2Dのキャプチャ画像の合成と配信を担当します。
3.Civetwebの導入
上述した通り、Unreal Engineに内包されているHTTPServerだとMotion JPEGが実装できなかったので、CivetwebというC++の簡易HTTPServerを導入します。
以下のGithubからコードをダウンロードしてください。
https://github.com/civetweb/civetweb
Civetwebダウンロードしたら、プロジェクトフォルダ内のSource/[プロジェクト名]配下にCivetwebというフォルダを作成し、以下のファイルを全てコピーしてください。
- /include/civetweb.h
- /source/civetweb.h
- /source/handle_form.inl
- /source/mathc.inl
- /source/md5.inl
- /source/reponse.inl
- /source/sort.inl

4.Build.csをCivetwebを読み込むように修正
Unreal C++のBuild.csファイルに上記で配置したCivetwebを読み込むように追記します。
// Fill out your copyright notice in the Description page of Project Settings.
using UnrealBuildTool;using System.IO;
public class AC2024 : ModuleRules{ public AC2024(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore" });
PrivateDependencyModuleNames.AddRange(new string[] { });
// Civetwebのヘッダーファイルとソースファイルのパスを設定 string CivetwebPath = Path.Combine(ModuleDirectory, "Civetweb"); PublicIncludePaths.Add(CivetwebPath);
// Civetwebのソースファイルを手動でコンパイルに含める PrivateDependencyModuleNames.Add("Core"); RuntimeDependencies.Add(Path.Combine(CivetwebPath, "civetweb.c")); PublicDefinitions.Add("NO_SSL");
// Uncomment if you are using Slate UI // PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
// Uncomment if you are using online features // PrivateDependencyModuleNames.Add("OnlineSubsystem");
// To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true }}5.WorldSubsystem経由でWEBサーバを起動するようにする
WorldSubsystemを継承したクラスを作成します。名前は“MyWorldSubsystem”としました。このクラスはBPから“MyMotionJpegServer”を呼び出す橋渡しだけの機能となっています。(ソースコードは後述)

6.描画ターゲットを2つ作成し、解像度を調整する
描画ターゲットを2つ作成し、それぞれ“RT_ThirdPersonEyeLeft”と“RT_ThirdPersonEyeRight”という名前で定義しました。こちらは、プレイヤーキャラクターの左目・右目のSceneCapture2Dの描画先となります。

描画ターゲットのサイズは320×360とします。これは、左目・右目を並べた時に640×320の解像度になるようにしたかった為です。(MyMotionJpegServerのCombineFramesというメソッド内で画像を左右で連結していますが、解像度が異なるとエラーになるので注意してください)

7.BP_ThirdPersonCharacterにSceneCapture2Dコンポーネントを2つ追加する
BP_ThirdPersonCharacterを開き、Mesh(CharacterMesh0)の子コンポーネントとしてSceneCapture2Dコンポーネントを2つ追加します。Meshのheadを親ソケットに設定し、それぞれ左右の目の位置に来るように位置を調整してください。

この時、両方のSceneCapture2DコンポーネントのCaptureSourceを必ず“FinalColor”にしてください。

それぞれのTextureTargetを上記6.で作成した左目・右目用の描画ターゲットを選択して設定は完了です。
8.適当なキーを押下したらMotionJPEGサーバが起動するようにする
ゲームプレイ後に適当なキーを押してMotion JPEGサーバを起動するように、BP_ThirdPersonCharacterのイベントグラフを以下のように組みます。(今回はMキーを押下するとMotion JPEGサーバが起動するようにしました)
LeftRenderTargetには左目のSceneCapture2Dカメラの描画ターゲットを、RightRenderTargetには右目のSceneCapture2Dの描画ターゲットを指定してください。
Portの値は9001にしていますが、起動するPCで使っていないポートなら何でもいいです。これ以降の説明では9001前提で話しを進めますので、ポートの意味が分からない場合は9001にしておいてください。
これは上記で作成したMyWorldSubsystem経由でMotion JPEGサーバ(MyMotionJpegServer)を起動しているだけです。(サーバの停止はMyWorldSubsystemの終了時イベントでコントロールしています)

9.ポート開放など
これで、ゲームをプレイしMキーを押すとMotion JPEGサーバが起動しプレイヤーキャラクターの視界映像がストリーミング配信されます。
ゲームプレイした環境とは別の端末(例えば、UE環境のPCと同じネットワークに接続されたスマホ)から接続する場合はPCのファイアーウォールの設定が必要な場合があります。
Windows 11の場合は“Windows Defender ファイアーウォール”を開き、受信の規則にTCPの9001ポートの受信許可を登録する必要があります。

10.Motion JPEGにアクセスしストリーミング配信を閲覧
ブラウザから以下のURLを入力することで、プレイヤーキャラクターの視界のストリーミング配信を閲覧することができるようになります。
http://[UEを起動しているマシンのIPアドレス]:9001/mjpegUEが起動しているマシンのブラウザで確認する場合はlocalhostでもアクセス可能です。
http://localhost:9001/mjpegUEが起動しているマシンのブラウザ以外から確認する場合は、UEが起動しているマシンのIPアドレスを調べてからURLにアクセスしてください。Windowsの場合はコマンドプロンプトを開き“ipconfig”コマンドでマシンのIPアドレスを確認することができます。
ソースコード
MyMotionJpegServer.h
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"#include "CivetWeb/civetweb.h"#include "Engine/TextureRenderTarget2D.h"
/** * */class AC2024_API MyMotionJpegServer{public: MyMotionJpegServer(); ~MyMotionJpegServer();
void Start(UTextureRenderTarget2D* InLeftRenderTarget, UTextureRenderTarget2D* InRightRenderTarget, int32 InPort); void Stop(); static TArray<FColor> CaptureFrame(UTextureRenderTarget2D* RenderTarget); static TArray<uint8> EncodeToJPEG(const TArray<FColor>& Pixels, int32 Width, int32 Height);
private: TWeakObjectPtr<UTextureRenderTarget2D> LeftRenderTarget; TWeakObjectPtr<UTextureRenderTarget2D> RightRenderTarget;
int32 Port; bool bIsRunning; FCriticalSection CriticalSection; struct mg_context* Context; TUniquePtr<FThread> ServerThread;
void RunServer(); static int HandleMjpegRequest(struct mg_connection* conn, void* cbdata); static TArray<FColor> CombineFrames(const TArray<FColor>& LeftPixels, const TArray<FColor>& RightPixels, int32 Width, int32 Height);};MyMotionJpegServer.cpp
// Fill out your copyright notice in the Description page of Project Settings.#include "MyMotionJpegServer.h"#include "ImageUtils.h"#include "IImageWrapper.h"#include "IImageWrapperModule.h"#include "Modules/ModuleManager.h"#include "RenderingThread.h"#include "Async/Async.h"
/** * @brief Construct a new My Motion Jpeg Server:: My Motion Jpeg Server object * */MyMotionJpegServer::MyMotionJpegServer(): Context(nullptr){ // デフォルトポートの設定 this->Port = 8080;
// サーバスレッドの実行フラグ this->bIsRunning = false;}
/** * @brief Destroy the My Motion Jpeg Server:: My Motion Jpeg Server object * */MyMotionJpegServer::~MyMotionJpegServer(){ // サーバの停止 Stop();}
/** * @brief MotionJpegサーバの始動 * * @param InLeftRenderTarget 左目カメラのRenderTarget2D * @param InRightRenderTarget 右目カメラのRenderTarget2D * @param InPort MotionJpegサーバのポート番号 */void MyMotionJpegServer::Start(UTextureRenderTarget2D* InLeftRenderTarget, UTextureRenderTarget2D* InRightRenderTarget, int32 InPort){ // RenderTargetがnullまたは無効な場合はエラーを出力して終了 if (!InLeftRenderTarget || !InLeftRenderTarget->IsValidLowLevelFast() || !InRightRenderTarget || !InRightRenderTarget->IsValidLowLevelFast()) { UE_LOG(LogTemp, Error, TEXT("Start: One or both RenderTargets are null or invalid.")); return; }
// 引数をメンバに保持 LeftRenderTarget = InLeftRenderTarget; RightRenderTarget = InRightRenderTarget; Port = InPort; bIsRunning = true;
// サーバスレッドの開始 ServerThread = MakeUnique<FThread>(TEXT("MotionJpegServerThread"), [this]() { RunServer(); });}
/** * @brief MotionJpegサーバの停止 * */void MyMotionJpegServer::Stop(){ // サーバスレッドの同期 FScopeLock Lock(&CriticalSection); bIsRunning = false;
// サーバスレッドの終了 if (ServerThread.IsValid()) { ServerThread->Join(); // サーバスレッドの終了を待つ ServerThread.Reset(); }
// MotionJpegサーバの停止 if (Context) { mg_stop(Context); Context = nullptr; } UE_LOG(LogTemp, Log, TEXT("HTTP Server stopped"));}
/** * @brief RenderTargetからフレームをキャプチャ * * @param RenderTarget 対象となるRenderTarget2D * @return TArray<FColor> キャプチャしたフレームのピクセルデータ */TArray<FColor> MyMotionJpegServer::CaptureFrame(UTextureRenderTarget2D* RenderTarget){ // 戻り値のピクセルデータ TArray<FColor> Pixels;
// RenderTargetがnullまたは無効な場合はエラーを出力して終了 if (!RenderTarget || !RenderTarget->IsValidLowLevelFast()) { UE_LOG(LogTemp, Error, TEXT("CaptureFrame: RenderTarget is null or invalid.")); return Pixels; }
// ピクセルデータの取得 FGraphEventRef Task = FFunctionGraphTask::CreateAndDispatchWhenReady([&]() { FTextureRenderTargetResource* RenderTargetResource = RenderTarget->GameThread_GetRenderTargetResource(); if (RenderTargetResource) { RenderTargetResource->ReadPixels(Pixels); } }, TStatId(), nullptr, ENamedThreads::GameThread);
// ピクセルデータの取得を待つ FTaskGraphInterface::Get().WaitUntilTaskCompletes(Task);
// ピクセルデータの返却 return Pixels;}
/** * @brief ピクセルデータをJPEGにエンコード * * @param Pixels ピクセルデータ * @param Width 幅 * @param Height 高さ * @return TArray<uint8> JPEGエンコードされたデータ */TArray<uint8> MyMotionJpegServer::EncodeToJPEG(const TArray<FColor>& Pixels, int32 Width, int32 Height){ int32 Quality = 75;
TArray<uint8> JpegData;
// IImageWrapperModuleを取得 IImageWrapperModule& ImageWrapperModule = FModuleManager::LoadModuleChecked<IImageWrapperModule>(FName("ImageWrapper"));
// JPEG用のImageWrapperを作成 TSharedPtr<IImageWrapper> ImageWrapper = ImageWrapperModule.CreateImageWrapper(EImageFormat::JPEG); if (!ImageWrapper.IsValid()) { UE_LOG(LogTemp, Error, TEXT("Failed to create ImageWrapper for JPEG.")); return JpegData; }
// ピクセルデータをBGRAからRGBAに変換 TArray<uint8> RawData; RawData.SetNum(Width * Height * 4); for (int32 i = 0; i < Pixels.Num(); ++i) { RawData[i * 4 + 0] = Pixels[i].R; RawData[i * 4 + 1] = Pixels[i].G; RawData[i * 4 + 2] = Pixels[i].B; RawData[i * 4 + 3] = Pixels[i].A; }
// 画像データをJPEGフォーマットに圧縮 if (ImageWrapper->SetRaw(RawData.GetData(), RawData.Num(), Width, Height, ERGBFormat::RGBA, 8)) { JpegData = ImageWrapper->GetCompressed(Quality); } else { UE_LOG(LogTemp, Error, TEXT("Failed to compress image to JPEG.")); }
return JpegData;}
/** * @brief 画像を左右に並べて結合 * * @param LeftPixels 左画像ピクセル * @param RightPixels 右画像ピクセル * @param Width 幅 * @param Height 高さ * @return TArray<FColor> 結合された画像ピクセル */TArray<FColor> MyMotionJpegServer::CombineFrames(const TArray<FColor>& LeftPixels, const TArray<FColor>& RightPixels, int32 Width, int32 Height){ TArray<FColor> CombinedPixels; CombinedPixels.SetNum(Width * 2 * Height);
for (int32 Y = 0; Y < Height; ++Y) { for (int32 X = 0; X < Width; ++X) { // 左の画像ピクセル CombinedPixels[Y * Width * 2 + X] = LeftPixels[Y * Width + X]; // 右の画像ピクセル CombinedPixels[Y * Width * 2 + X + Width] = RightPixels[Y * Width + X]; } }
return CombinedPixels;}
/** * @brief MotionJpegサーバの起動 * */void MyMotionJpegServer::RunServer(){ // ポート番号の文字列化 const FString PortStr = FString::FromInt(Port);
// CivetWebのオプション設定 const char* options[] = { "listening_ports", TCHAR_TO_ANSI(*PortStr), nullptr };
// HTTPサーバの開始 { FScopeLock Lock(&CriticalSection); Context = mg_start(nullptr, nullptr, options); }
// HTTPサーバの開始に失敗した場合はエラーを出力して終了 if (!Context) { UE_LOG(LogTemp, Error, TEXT("Failed to start HTTP Server on port %d"), Port); return; }
// HTTPリクエストハンドラの設定(mjpegにアクセスした時の処理) mg_set_request_handler(Context, "/mjpeg", HandleMjpegRequest, this); UE_LOG(LogTemp, Log, TEXT("HTTP Server started on port %d"), Port);}
/** * @brief MotionJpegストリーミング処理 * * @param conn CivetWebのコネクション * @param cbdata CivetWebのコールバックデータ * @return int ステータスコード */int MyMotionJpegServer::HandleMjpegRequest(struct mg_connection* conn, void* cbdata){ // Civetweb取得 MyMotionJpegServer* Server = static_cast<MyMotionJpegServer*>(cbdata);
// RenderTargetがnullまたは無効な場合はエラーを出力して終了 if (!Server || !Server->LeftRenderTarget.IsValid() || !Server->RightRenderTarget.IsValid()) { mg_printf(conn, "HTTP/1.1 500 Internal Server Error\\\\"); return 500; }
// HTTPレスポンスヘッダの設定 mg_printf(conn, "HTTP/1.1 200 OK\\" "Content-Type: multipart/x-mixed-replace; boundary=frame\\\\");
// フレームのキャプチャとストリーミング while (Server->bIsRunning && conn != nullptr) { // 左右のRenderTargetからフレームをキャプチャ TArray<FColor> LeftPixels = CaptureFrame(Server->LeftRenderTarget.Get()); TArray<FColor> RightPixels = CaptureFrame(Server->RightRenderTarget.Get());
// フレームのキャプチャに失敗した場合はエラーを出力して終了 if (LeftPixels.Num() == 0 || RightPixels.Num() == 0) { UE_LOG(LogTemp, Warning, TEXT("HandleMjpegRequest: Failed to capture frame.")); break; }
// 左右のフレームを結合 TArray<FColor> CombinedPixels = CombineFrames(LeftPixels, RightPixels, Server->LeftRenderTarget->SizeX, Server->LeftRenderTarget->SizeY);
// JPEGエンコード TArray<uint8> JpegData = EncodeToJPEG(CombinedPixels, Server->LeftRenderTarget->SizeX * 2, Server->LeftRenderTarget->SizeY);
// JPEGストリーミング int written = mg_printf(conn, "--frame\\" "Content-Type: image/jpeg\\" "Content-Length: %zu\\\\", JpegData.Num()); if (written <= 0){ break; }
written = mg_write(conn, JpegData.GetData(), JpegData.Num()); if (written <= 0){ break; }
written = mg_printf(conn, "\\"); if (written <= 0){ break; }
// フレーム間隔待機 FPlatformProcess::Sleep(0.033); // フレーム間隔(30FPS) }
// ステータスコードの返却 return 200;}MyWorldSubsystem.h
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"#include "Subsystems/WorldSubsystem.h"#include "MyMotionJpegServer.h"#include "MyWorldSubsystem.generated.h"
/** * */UCLASS()class AC2024_API UMyWorldSubsystem : public UWorldSubsystem{ GENERATED_BODY()
public: // Subsystemの初期化処理 virtual void Initialize(FSubsystemCollectionBase& Collection) override;
// Subsystemの終了処理 virtual void Deinitialize() override;
// MotionJpegServerを開始する UFUNCTION(BlueprintCallable, Category = "MotionJpegServer") void StartMotionJpegServer(UTextureRenderTarget2D* LeftRenderTarget, UTextureRenderTarget2D* RightRenderTarget, int32 Port);
private: // MotionJpegServer TUniquePtr<MyMotionJpegServer> MotionJpegServer;};MyWorldSubsystem.cpp
// Fill out your copyright notice in the Description page of Project Settings.
#include "MyWorldSubsystem.h"
/** * @brief コンストラクタ * * @param Collection */void UMyWorldSubsystem::Initialize(FSubsystemCollectionBase& Collection){ Super::Initialize(Collection);}
/** * @brief デストラクタ * */void UMyWorldSubsystem::Deinitialize(){ // MotionJpegServerを安全に解放 if (MotionJpegServer) { MotionJpegServer->Stop(); MotionJpegServer.Reset(); }
Super::Deinitialize();}
/** * @brief MotionJpegServerを開始する * * @param LeftRenderTarget 左目カメラのRebderTarget2D * @param RightRenderTarget 右目カメラのRenderTarget2D * @param Port 起動ポート */void UMyWorldSubsystem::StartMotionJpegServer(UTextureRenderTarget2D* LeftRenderTarget, UTextureRenderTarget2D* RightRenderTarget, int32 Port){ if (!MotionJpegServer.IsValid()) { // MotionJpegServerを作成して起動 MotionJpegServer = MakeUnique<MyMotionJpegServer>(); MotionJpegServer->Start(LeftRenderTarget, RightRenderTarget, Port); }}