UnityでのリアルタイムMJPEGストリーミング(XRデバイス対応)

はじめに


 

このブログでは、UnityでHTTPストリーミング形式のMJPEGをデコードする方法について解説します。リアルタイムでカメラのストリーミング映像を表示する必要があるアプリケーションは多く、ネイティブで効率的なソリューションの実装が不可欠です。しかし、UnityにはMJPEGストリームをデコードする機能が備わっていません。そのため、ストリーム内の画像データを適切に分割し処理できる独自の手法を開発する必要があります。

基本概念:MJPEGとは?


MJPEG(Motion JPEG)は、動画圧縮フォーマットの一種であり、各フレームが独立したJPEG画像として処理される方式です。つまり、フレーム間の圧縮(時間的圧縮)を使用せず、各フレームを個別にエンコードします。

この特性により、MJPEGにはいくつかのメリットがあります:

 シンプルなデコード処理
各フレームが独立したJPEG画像であるため、デコードプロセスが単純であり、フレームの予測や補完などの複雑な計算が不要です。

高い耐障害性(ロバスト性)
もし一部のフレームが失われても、他のフレームには影響を与えません。そのため、映像の一貫性が維持されやすいのが特徴です。

実装が容易
JPEGは広くサポートされているフォーマットであり、ハードウェア/ソフトウェアともに最適化されているため、低遅延・高互換性が求められる用途に適しています。

しかし、このフレーム独立型の構造にはいくつかのデメリットもあります。

🔼高い帯域幅の消費:フレームごとに圧縮されるため、従来の動画コーデック(H.264, VP9など)に比べてデータサイズが大きくなります。
🔼圧縮効率の低さ:時間的圧縮を行わないため、ストレージやネットワーク負荷が高くなりやすい。

考えられる解決策とその制約


HTTP経由のMJPEGストリームをUnityでデコードする方法を検討した結果、いくつかの既存のアプローチそれぞれの制約が明らかになりました。以下に、それらの方法と問題点を整理します。

1. FFmpegを使用したMJPEGデコード

FFmpegは、動画や音声の処理において非常に強力で柔軟なツールです。MJPEGストリームをデコードし、リアルタイムでフレームを抽出してUnityに表示することが可能です。

制約

  • AndroidやXRデバイスでは非対応
    FFmpegは主にWindowsやLinux向けに設計されており、モバイル環境やXRデバイスでは動作が難しい。
  • 遅延が発生しやすい
    ストリームの変換・デコード処理により、Unity上での映像表示にタイムラグが生じる。
  • アプリサイズの肥大化
    追加のライブラリや実行ファイルを同梱する必要があり、アプリのサイズが大きくなる。

2. UnityのWebCamTextureを使用

Unityには、WebCamTexture というAPIがあり、カメラ映像をリアルタイムでテクスチャとして取得できます。

制約

  • ローカルカメラ専用
    WebCamTextureローカル接続のカメラ(USBカメラやデバイス内蔵カメラ)のみ対応しており、HTTP経由のMJPEGストリームには対応していない

3. UnityのVideoPlayerを使用

UnityのVideoPlayer コンポーネントは、シーン内で動画を再生でき、さまざまなフォーマットをサポートしています。

制約

  • MJPEGストリームの対応なし
    VideoPlayer動画ファイルを再生するための機能であり、リアルタイムのHTTPストリーミングには対応していない
  • 事前のファイル保存が必要
    動画ファイルをローカルに保存してから再生する設計になっており、ストリームのフレームを直接処理できない。

4. Unity Render Streaming(VideoStreamReceiver)を使用

UnityのRender Streaming機能を利用すると、WebRTCを使用した低遅延の映像ストリーミングが可能です。

制約

  • MJPEG(HTTPストリーム)には非対応
    VideoStreamReceiverWebRTCベースであり、VP8やH.264などのコーデックを使用するため、HTTPベースのMJPEGストリームを直接処理できない
  • WebRTCサーバーが必要
    WebRTCはピアツーピア通信を行うため、シグナリングサーバーの構築が必須となる。
  • MJPEGを送信するIPカメラとは互換性がない
    WebRTC対応のストリーミングソースが必要で、通常のMJPEGカメラから直接ストリームを受信することはできない

UnityにおけるネイティブなMJPEGデコードの実装


UnityはHTTP経由のMJPEGストリームをネイティブに処理する機能を提供していません。また、FFmpegの依存、Android/XRの互換性問題、高遅延、WebRTCの要件など、既存の代替手段にはさまざまな制約があります。そこで、Unity内で直接リアルタイムに効率的なMJPEGストリームのデコードを行う実装を開発しました。

 

📌 目標

外部ツールを使用せず、Unity内でMJPEGストリームを処理
フレーム管理を最適化し、遅延を最小限に抑える
Windows、Android、XRデバイスを含むマルチプラットフォーム対応
RenderTextureとシェーダーを活用し、パフォーマンスを向上

 

🛠 ソリューションのアーキテクチャ

1️⃣ MJPEGストリームの接続開始

  • 指定されたURLにHTTPリクエストを送信し、ストリームを開く。
  • 別スレッドでデータを取得し、Unityのメインスレッドをブロックしないようにする。

2️⃣ MJPEGストリームからJPEG画像を抽出

  • JPEGの開始(0xFFD8)と終了(0xFFD9)のマーカーを検出し、フレームを識別。
  • フレームごとにConcurrentQueue<byte[]>を利用して、画像データを格納。

3️⃣ Unityでのデコードとテクスチャ更新

  • Update()内で最新の画像データを取得し、Texture2Dに変換
  • Graphics.Blit()を使用し、RenderTextureへ転送(CPU負荷を軽減)。
  • RenderTextureRawImageに適用し、Unity内で映像をリアルタイム表示。

4️⃣ 遅延を最小限に抑える最適化

  • 最新のフレームのみを保持し、古いフレームを破棄することで遅延を防ぐ。
  • GPU処理の最適化(シェーダーを活用し、CPU負荷を軽減)。

 

📝 ソースコード


  1. using System.Collections.Concurrent;
  2. using System.IO;
  3. using System.Net;
  4. using System.Threading;
  5. using UnityEngine;
  6. using UnityEngine.UI;
  7. public class CameraStreamMjpegManager : MonoBehaviour
  8. {
  9.     [SerializeField] string streamUrl; // URL of the stream
  10.     [SerializeField] RawImage videoDisplay; // For showing the video stream
  11.     [SerializeField] private Material videoMaterial; // Material with shader
  12.     private ConcurrentQueue<byte[]> frameQueue = new ConcurrentQueue<byte[]>();
  13.     private Thread worker;
  14.     private bool isRunning = false;
  15.     private Texture2D texture2D;
  16.     private RenderTexture renderTexture;
  17.     private const float RETRY_DELAY = 5f;
  18.     private const int MAX_RETRIES = 3;
  19.     private const int STREAM_WIDTH = 640;
  20.     private const int STREAM_HEIGHT = 480;
  21.     void Start()
  22.     {
  23.         renderTexture = new RenderTexture(STREAM_WIDTH, STREAM_HEIGHT, 0); // RenderTexture size
  24.         videoDisplay.texture = renderTexture;
  25.         StartStream(streamUrl);
  26.     }
  27.     void Update()
  28.     {
  29.         // Discard old frames and keep only the latest one
  30.         while (frameQueue.Count > 1)
  31.         {
  32.             frameQueue.TryDequeue(out _);
  33.         }
  34.         if (frameQueue.TryDequeue(out byte[] frame))
  35.         {
  36.             if (texture2D == null)
  37.             {
  38.                 texture2D = new Texture2D(STREAM_WIDTH, STREAM_HEIGHT, TextureFormat.RGB24, false);
  39.             }
  40.             if (texture2D.LoadImage(frame, false))
  41.             {
  42.                 if (renderTexture == null || renderTexture.width != texture2D.width || renderTexture.height != texture2D.height)
  43.                 {
  44.                     renderTexture = new RenderTexture(texture2D.width, texture2D.height, 0);
  45.                 }
  46.                 Graphics.Blit(texture2D, renderTexture, videoMaterial);
  47.             }
  48.         }
  49.     }
  50.     public void StartStream(string url)
  51.     {
  52.         if (isRunning) StopStream();
  53.         worker = new Thread(() => StreamWorker(url));
  54.         worker.Start();
  55.         isRunning = true;
  56.     }
  57.     public void StopStream()
  58.     {
  59.         isRunning = false;
  60.         if (worker != null && worker.IsAlive)
  61.         {
  62.             worker.Abort();
  63.         }
  64.     }
  65.     private void StreamWorker(string url)
  66.     {
  67.         int retryCount = 0;
  68.         while (retryCount < MAX_RETRIES)
  69.         {
  70.             try
  71.             {
  72.                 HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
  73.                 request.Method = "GET";
  74.                 using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
  75.                 using (Stream stream = response.GetResponseStream())
  76.                 {
  77.                     byte[] buffer = new byte[1024 * 16]; // 16kb buffer
  78.                     MemoryStream frameBuffer = new MemoryStream();
  79.                     bool isFrame = false;
  80.                     int lastByte = 0;
  81.                     while (isRunning && stream.CanRead)
  82.                     {
  83.                         int bytesRead = stream.Read(buffer, 0, buffer.Length);
  84.                         if (bytesRead <= 0) break;
  85.                         for (int i = 0; i < bytesRead; i++)
  86.                         {
  87.                             int newByte = buffer[i];
  88.                             if (isFrame)
  89.                             {
  90.                                 frameBuffer.WriteByte((byte)newByte);
  91.                                 if (lastByte == 0xFF && newByte == 0xD9) // End of Image
  92.                                 {
  93.                                     frameQueue.Enqueue(frameBuffer.ToArray());
  94.                                     frameBuffer.SetLength(0); // clear buffer
  95.                                     isFrame = false;
  96.                                 }
  97.                             }
  98.                             else if (lastByte == 0xFF && newByte == 0xD8) // Start of Image
  99.                             {
  100.                                 isFrame = true;
  101.                                 frameBuffer.WriteByte(0xFF);
  102.                                 frameBuffer.WriteByte(0xD8);
  103.                             }
  104.                             lastByte = newByte;
  105.                         }
  106.                     }
  107.                 }
  108.             }
  109.             catch
  110.             {
  111.                 retryCount++;
  112.                 Thread.Sleep((int)(RETRY_DELAY * 1000));
  113.             }
  114.         }
  115.     }
  116.     private void OnDestroy()
  117.     {
  118.         StopStream();
  119.         if (texture2D != null)
  120.         {
  121.             Destroy(texture2D);
  122.         }
  123.         if (renderTexture != null)
  124.         {
  125.             renderTexture.Release();
  126.             Destroy(renderTexture);
  127.         }
  128.     }
  129. }

シェーダー

  1. Shader "Custom/VideoTextureLoad"
  2. {
  3.     Properties
  4.     {
  5.         _MainTex ("Texture", 2D) = "white" {}
  6.     }
  7.     SubShader
  8.     {
  9.         Tags { "Queue" = "Overlay" "RenderType" = "Transparent" }
  10.         Pass
  11.         {
  12.             CGPROGRAM
  13.             #pragma vertex vert
  14.             #pragma fragment frag
  15.             #include "UnityCG.cginc"
  16.             struct appdata_t {
  17.                 float4 vertex : POSITION;
  18.                 float2 uv : TEXCOORD0;
  19.             };
  20.             struct v2f {
  21.                 float2 uv : TEXCOORD0;
  22.                 float4 vertex : SV_POSITION;
  23.             };
  24.             sampler2D _MainTex;
  25.             v2f vert (appdata_t v)
  26.             {
  27.                 v2f o;
  28.                 o.vertex = UnityObjectToClipPos(v.vertex);
  29.                 o.uv = v.uv;
  30.                 return o;
  31.             }
  32.             fixed4 frag (v2f i) : SV_Target
  33.             {
  34.                 return tex2D(_MainTex, i.uv);
  35.             }
  36.             ENDCG
  37.         }
  38.     }
  39. }

まとめ


本記事では、UnityでMJPEGストリーミングをリアルタイムに処理する方法を解説しました。既存の手法には制約が多いため、低遅延・高パフォーマンスな独自の実装を開発し、Windows・Android・XRデバイスで動作可能なソリューションを実現しました。

今回の最適化されたMJPEGストリーミングの実装により、Unityを活用したリアルタイム映像処理の可能性が大きく広がります。特に、以下のような分野での応用が期待されます。

🎥 XR環境でのカメラ監視
 → 遠隔メンテナンス、工場の安全管理、セキュリティ監視など、XRデバイスを活用したリアルタイム映像表示。

🕹️ ドローンやロボットとのリアルタイム連携
 → 映像付き遠隔操作、AIロボットの映像フィードバック、環境監視などに活用可能。

🚗 自動車向けのリアルタイム映像支援システム
 → 自動運転支援、運転者への警告システム、後方・側方カメラのリアルタイム表示。

👨‍⚕️ 医療分野での映像ストリーミング
 → 手術室のライブ映像配信、医療用顕微鏡の画像共有、リモート診断などの用途。 

上記ブログの内容に少しでも興味がありましたら、お気軽にご連絡ください。

弊社のエンジニアがフレンドリーに対応させていただきます。