はじめに

数年前から話題に上がっている、3Dシーンを再現する技術である3D Gaussian Splattingを今更ながら触ってみました。通称”3DGS”と呼ばれることが多いみたいなので、今後記事内では”3DGS”と呼びます。
Unityで3DGSを扱うとしたら以下のようなライブラリを使用するのが選択肢に上がってくるかと思います。

今回は3DGSの高圧縮ファイルである「.spz」を扱いたかったので、 UnityGaussianSplattingを使用することにしました。

開発環境

  • Unity 6.2 (6000.2.0f1)
  • UnityGaussianSplatting v1.1.1
  • Meta XR Core SDK v77
  • Meta XR Interaction SDK v77

動作確認端末

  • Meta Quest 3S
  • Pixel 10
  • iPhone 12 Pro Max

「.spz」ファイル

.spz」とはNianticが開発した、3DGSの高圧縮フォーマットです。Scaniverseでスキャンしたなら、そのまま「.spz」でエクスポートすることができます。
3DGSのファイルフォーマットは「.ply」を使用することが多いですが、「.ply」に比べるとファイルサイズが約90%ほど小さくなります。まさに、モバイル端末でランタイムに読み込むにはうってつけだと思います。

しかし、現状「.spz」に対応しているライブラリはUnityGaussianSplattingぐらいしか見当たりませんでした。環境によっては、オープンソースで公開されているので、自力でインポーターを開発する必要があります。
公式のコミュニティによるとUnityのインポーターは開発中とのことです。

3DGSの作成

無料で3DGSを作成する方法として、LumaAIも有名ですが、個人的にはScaniverseが一番使いやすいと思います。精度は落ちますが、LiDAR非搭載端末のiPhoneやAndroidでも3DGSを作成することができます。
いろんな角度の写真をたくさん撮る必要はなく、動画を撮影するように、対象の周りを映すことで1分ほどで作成することができます。
スキャンはiPhone 12 Pro Maxで行っています。

処理完了後にそのままエクスポートすることができます。

ランタイムで読み込み

「.spz」や「.ply」をそのまま読み込めるライブラリは、自分で探した限りでは見つかりませんでした。
一旦UnityEditor上で取り組んだ後、それぞれ独自のAssetに変換してから描画しています。
UnityGaussianSplattingもそのまま読み込む機能がないため、ランタイムで読み込めるように改修しました。

1.EditorスクリプトをRuntimeスクリプトに移動

まず、UnityGaussianSplattingのプロジェクトをCloneかダウンロードしてきたら、packageディレクトリをそのまま自分のプロジェクトに持ってくるのが良いと思います。
その後に、以下のEditorスクリプトをpackage/Runtime/Utils配下に移動します。

  • package/Editor/Utils
    • SPZFileReader
    • PLYFileReader
    • KMeansClustering
    • GaussianFileReader

移動後はそれぞれnamespaceも改修しておきましょう。これだけでエラーはでないと思います。

namespace GaussianSplatting.Editor.Utils {}
↓
namespace GaussianSplatting.Runtime.Utils {}

2.GaussianSplatAssetを作成する

GaussianSplatAssetはUnityGaussianSplatting上で描画するのに必要になるScriptableObjectです。
本来はEditor上で「.ply」か「.spz」を読み込んで、GaussianSplatAssetに変換するのですが、それをランタイムで行うように改修します。
基本的にはpackage/Editor/Utils/GaussianSplatAssetCreator.csから必要箇所をコピーしたきたら良いです。以下は読み込み部分の一部サンプルです。

/// <summary>
/// ランタイムでSPZファイルの読み込み
/// </summary>
public class RuntimeSPZLoader : MonoBehaviour
{
   enum DataQuality
   {
       VeryHigh,
       High,
       Medium,
       Low,
       VeryLow,
       Custom,
   }
        
   [Header("Quality Settings")]
   [SerializeField] 
   DataQuality m_Quality = DataQuality.Medium;
   [SerializeField] 
   GaussianSplatAsset.VectorFormat m_FormatPos = GaussianSplatAsset.VectorFormat.Norm11;
   [SerializeField] 
   GaussianSplatAsset.VectorFormat m_FormatScale = GaussianSplatAsset.VectorFormat.Norm11;
   [SerializeField] 
   GaussianSplatAsset.ColorFormat m_FormatColor = GaussianSplatAsset.ColorFormat.Norm8x4;
   [SerializeField] 
   GaussianSplatAsset.SHFormat m_FormatSH = GaussianSplatAsset.SHFormat.Norm6;

   /// <summary>
   /// SPZファイルを読み込み、GaussianSplatAssetを生成する
   /// </summary>
   /// <param name="filePath">spzファイルのパス</param>
   /// <returns>描画用のアセット</returns>
   public GaussianSplatAsset LoadSPZFile(string filePath)
   {
      Debug.Log("Loading SPZ file: " + filePath);
            
      // Validate file path
      if (!File.Exists(filePath))
      {
          Debug.LogError($"SPZ file not found: {filePath}");
          return null;
      }
            
      // Read SPZ file data
      NativeArray<InputSplatData> splatData;
      SPZFileReader.ReadFile(filePath, out splatData);
            
      if (!splatData.IsCreated || splatData.Length == 0)
      {
          Debug.LogError("Failed to read SPZ file or no data found");
          return null;
      }
            
      Debug.Log($"Loaded {splatData.Length} splats from SPZ file");
            
      var splatAsset = createSplatAsset(splatData,filePath);
            
      // Clean up native array
      splatData.Dispose();
            
      return splatAsset;
  }     

  /// <summary>
  /// 描画用のアセット生成
  /// </summary>
  /// <param name="inputSplats">読み込んだ3DGSのデータ</param>
  /// <param name="spzFilePath">spzのファイルパス</param>
  /// <returns>描画用のアセット</returns>
  unsafe GaussianSplatAsset createSplatAsset(NativeArray<InputSplatData> inputSplats,string spzFilePath)
  {
      float3 boundsMin, boundsMax;
      var boundsJob = new CalcBoundsJob
      {
          m_BoundsMin = &boundsMin,
          m_BoundsMax = &boundsMax,
          m_SplatData = inputSplats
      };
      boundsJob.Schedule().Complete();
            
      reorderMorton(inputSplats, boundsMin, boundsMax);
            
      string baseName = Path.GetFileNameWithoutExtension(spzFilePath);
            
      GaussianSplatAsset asset = ScriptableObject.CreateInstance<GaussianSplatAsset>();
      GaussianSplatAsset.CameraInfo[] cameras = null;
      asset.Initialize(inputSplats.Length, m_FormatPos, m_FormatScale, m_FormatColor, m_FormatSH, boundsMin, boundsMax, cameras);
      asset.name = $"runtime_{baseName}";
            
      var dataHash = new Hash128((uint)asset.splatCount, (uint)asset.formatVersion, 0, 0);
      var chunkAsset = CreateChunkData(inputSplats, ref dataHash);
      var posAsset = CreatePositionsData(inputSplats,ref dataHash);
      var otherAsset = CreateOtherData(inputSplats, ref dataHash, default);
      var colorAsset = CreateColorData(inputSplats, ref dataHash);
      var shAsset = CreateSHData(inputSplats,ref dataHash, default);
      asset.SetDataHash(dataHash);
      asset.SetAssetFiles(chunkAsset,posAsset,otherAsset,colorAsset,shAsset);
            
      return asset;
  }
}

以下の部分は、GaussianSplatAssetCreatorではTextAssetファイルを保存しているのですが、保存せずにそのまま返り値にしています。

var chunkAsset = CreateChunkData(inputSplats, ref dataHash);
var posAsset = CreatePositionsData(inputSplats,ref dataHash);
var otherAsset = CreateOtherData(inputSplats, ref dataHash, default);
var colorAsset = CreateColorData(inputSplats, ref dataHash);
var shAsset = CreateSHData(inputSplats,ref dataHash, default);

CreatePositionsData()を例に以下のようにしています。

TextAsset CreatePositionsData(NativeArray<InputSplatData> inputSplats,ref Hash128 dataHash)
{
    int dataLen = inputSplats.Length * GaussianSplatAsset.GetVectorSize(m_FormatPos);
    dataLen = nextMultipleOf(dataLen, 8); // serialized as ulong
    NativeArray<byte> data = new(dataLen, Allocator.TempJob);

    CreatePositionsDataJob job = new CreatePositionsDataJob
    {
        m_Input = inputSplats,
        m_Format = m_FormatPos,
        m_FormatSize = GaussianSplatAsset.GetVectorSize(m_FormatPos),
        m_Output = data
    };
    job.Schedule(inputSplats.Length, 8192).Complete();

    dataHash.Append(data);
    
    var textAsset =  new TextAsset(data);
    data.Dispose();
    return textAsset;
}

リモートからダウンロードしたい時

UnityWebRequestを使用してリモートからファイルを読み込みたい時は、SPZFileReader.ReadFile()を以下のように改修してください。引数のrawDataにrequest.downloadHandler.dataを渡したら良いです。

//ローカルから読み込み
public static void ReadFile(string filePath, out NativeArray<InputSplatData> splats)
{
      using var fs = File.OpenRead(filePath);
      using var gz = new GZipStream(fs, CompressionMode.Decompress);
      ReadHeaderImpl(filePath, gz, out var splatCount, out var shLevel, out var fractBits, out var flags);
      //--- 以下省略 ---//
}

//リモートから読み込み
public static void ReadFile(byte[] rawData, string name , out NativeArray<InputSplatData> splats)
{
      using var fs = new MemoryStream(rawData);
      using var gz = new GZipStream(fs, CompressionMode.Decompress);
      ReadHeaderImpl(name, gz, out var splatCount, out var shLevel, out var fractBits, out var flags);
      //--- 以下省略 ---//
}

3.GaussianSplatRendererで描画する

後は、作成したGaussianSplatAssetをGaussianSplatRendererのAssetに設定します。

[SerializeField] private RuntimeSPZLoader _loader;
[SerializeField] private GaussianSplatRenderer _renderer;
private string _loadPath = $"{Application.persistentDataPath}/spz/hoge.spz";

var splatAsset = _loader.LoadSPZFile(_loadPath);
_renderer.m_Asset = splatAsset;

各種設定は添付のスクショみたいにするのがおすすめです。特にRenderOptionsはパフォーマンスに影響します。

実機デモ

Meta Quest 3S

FPSが10~30程度であまりパフォーマンスがでません。UnityGaussianSplattingはシングルパスに対応しておらずマルチパスで描画しています。シングルパスに対応したプルリクエストがありますが、試してみたところ、シングルパスで描画はできるようになったものの、FPSは上がりませんでした。
他に以下のような対応をしたら、5,6程度FPSは上がりました。

// 解像度を下げる
XRSettings.eyeTextureResolutionScale = 0.7f;

// Fixed Foveated Renderingを有効化
OVRManager.foveatedRenderingLevel = OVRManager.FoveatedRenderingLevel.High;

// Application SpaceWarpを有効化
OVRManager.SetSpaceWarp(true);

// FFR動的調整
OVRManager.useDynamicFixedFoveatedRendering = true;

Pixel 10

FPSが30~60程度で思ったよりはパフォーマンスがでてます。Unityのデフォルトだとバッテリー消費を抑えるために30FPSになるため、目標フレームレートを設定する必要があります。

// アプリケーションの目標フレームレートを60に設定
Application.targetFrameRate = 60;

iPhone 12 Pro Max

Pixel 10と同様に、FPSが30~60程度でています。iPhoneもデフォルトだとFPSが30なので、
目標フレームレートを60に設定する必要があります。

まとめ

パフォーマンスで課題が多いですが、モバイル端末でも意外と動かすことができました。Unityでランタイムに直接3DGSファイルを読む込むライブラリが無いということは、技術的にまだまだ難しいのかなと思います。
3DGSは綺麗にスキャンできた時は、本当に写真みたいで驚いたので、まだ未体験の方は試してみてください。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる