Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Added runtime PLY loading from byte arrays. A new method `LoadFromPlyBytes(byte[])` is added to `GsplatAsset` and its derived classes. ([#34](https://github.com/wuyize25/gsplat-unity/pull/34) by [@TakashiYoshinaga](https://github.com/TakashiYoshinaga))

- Added support for PlayCanvas SOG v2 ZIP bundle imports (`.sog`) in both Spark and Uncompressed compression modes. SOG import decodes `meta.json` plus lossless WebP property images through `unity.webp`/libwebp into the existing `GsplatAsset` runtime representations and supports SOG SH bands 0-3. The same SOG loader can be used at runtime on supported platforms including Android.

### Fixed

- Fixed PLY header parsing to count only vertex element properties. The package now supports PLY files exported by Apple's [ml-sharp](https://github.com/apple/ml-sharp). ([#33](https://github.com/wuyize25/gsplat-unity/pull/33) by [@TakashiYoshinaga](https://github.com/TakashiYoshinaga))
Expand Down Expand Up @@ -130,4 +132,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[1.0.3]: https://github.com/wuyize25/gsplat-unity/compare/v1.0.2...v1.0.3
[1.0.2]: https://github.com/wuyize25/gsplat-unity/compare/v1.0.1...v1.0.2
[1.0.1]: https://github.com/wuyize25/gsplat-unity/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/wuyize25/gsplat-unity/releases/tag/v1.0.0
[1.0.0]: https://github.com/wuyize25/gsplat-unity/releases/tag/v1.0.0
79 changes: 62 additions & 17 deletions Editor/GsplatImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,39 @@

namespace Gsplat.Editor
{
[ScriptedImporter(1, new[] { "ply", "spz" })]
[ScriptedImporter(1, new[] { "ply", "spz", "sog" })]
public class GsplatImporter : ScriptedImporter
{
public CompressionMode Compression = CompressionMode.Spark;

[Tooltip("The coordinate frame the source asset was authored in.\n\n" +
"Positions, rotations, and SH coefficients are converted to Unity (RUF) at import time.\n\n" +
"RUB — standard output of 3DGS training tools, gsplat, nerfstudio, and Niantic SPZ.\n" +
"RDB — PlayCanvas SOG.\n" +
"RDF — OpenCV, COLMAP camera convention.\n" +
"LUF — GLB, glTF.\n" +
"RUF — already in Unity space; no conversion applied.")]
public SourceCoordinates SourceCoordinates = SourceCoordinates.RUB;
public SourceCoordinates SourceCoordinates = SourceCoordinates.Unspecified;

public override void OnImportAsset(AssetImportContext ctx)
{
bool isSpz = ctx.assetPath.EndsWith(".spz", StringComparison.OrdinalIgnoreCase);
bool isSpz = IsSpz(ctx.assetPath);
bool isSog = IsSog(ctx.assetPath);
SourceCoordinates sourceCoordinates = SourceCoordinates == SourceCoordinates.Unspecified
? isSog ? SourceCoordinates.RDB : SourceCoordinates.RUB
: SourceCoordinates;
GsplatAsset gsplatAsset = Compression switch
{
CompressionMode.Uncompressed => isSpz
? ScriptableObject.CreateInstance<GsplatAssetSpzUncompressed>()
: ScriptableObject.CreateInstance<GsplatAssetUncompressed>(),
CompressionMode.Spark => isSpz
? ScriptableObject.CreateInstance<GsplatAssetSpz>()
: ScriptableObject.CreateInstance<GsplatAssetSpark>(),
CompressionMode.Uncompressed => isSog
? ScriptableObject.CreateInstance<GsplatAssetSogUncompressed>()
: isSpz
? ScriptableObject.CreateInstance<GsplatAssetSpzUncompressed>()
: ScriptableObject.CreateInstance<GsplatAssetUncompressed>(),
CompressionMode.Spark => isSog
? ScriptableObject.CreateInstance<GsplatAssetSog>()
: isSpz
? ScriptableObject.CreateInstance<GsplatAssetSpz>()
: ScriptableObject.CreateInstance<GsplatAssetSpark>(),
_ => throw new ArgumentOutOfRangeException()
};

Expand All @@ -42,22 +51,22 @@ public override void OnImportAsset(AssetImportContext ctx)
#else
Stopwatch swTotal = null;
#endif
SpzPhaseTimings spzTimings = default;
SpzPhaseTimings importTimings = default;
try
{
ProgressCallback progress = (info, p) => EditorUtility.DisplayProgressBar(
"Importing Gsplat Asset", info, p);

if (gsplatAsset is GsplatAssetSpzUncompressed spzUncompressedAsset)
{
spzTimings = spzUncompressedAsset.LoadFromSpz(ctx.assetPath, SourceCoordinates, progress);
importTimings = spzUncompressedAsset.LoadFromSpz(ctx.assetPath, sourceCoordinates, progress);
}
else if (gsplatAsset is GsplatAssetSpz spzAsset)
{
string cachePath = GetCachePath(ctx.assetPath, Compression, SourceCoordinates);
string cachePath = GetCachePath(ctx.assetPath, Compression, sourceCoordinates);
if (!spzAsset.TryLoadFromCache(cachePath))
{
spzTimings = spzAsset.LoadFromSpz(ctx.assetPath, SourceCoordinates, progress);
importTimings = spzAsset.LoadFromSpz(ctx.assetPath, sourceCoordinates, progress);
try
{
spzAsset.SaveToCache(cachePath);
Expand All @@ -68,9 +77,30 @@ public override void OnImportAsset(AssetImportContext ctx)
}
}
}
else if (gsplatAsset is GsplatAssetSogUncompressed sogUncompressedAsset)
{
importTimings = sogUncompressedAsset.LoadFromSog(ctx.assetPath, sourceCoordinates, progress);
}
else if (gsplatAsset is GsplatAssetSog sogAsset)
{
string cachePath = GetSogCachePath(ctx.assetPath, Compression, sourceCoordinates,
SogImageDecoder.DecoderVersion);
if (!sogAsset.TryLoadFromCache(cachePath))
{
importTimings = sogAsset.LoadFromSog(ctx.assetPath, sourceCoordinates, progress);
try
{
sogAsset.SaveToCache(cachePath);
}
catch (Exception e)
{
UnityEngine.Debug.LogWarning($"[Gsplat Import] Cache write failed: {e.Message}");
}
}
}
else
{
gsplatAsset.LoadFromPly(ctx.assetPath, progress, SourceCoordinates);
gsplatAsset.LoadFromPly(ctx.assetPath, progress, sourceCoordinates);
}
}
catch (Exception e)
Expand All @@ -93,7 +123,7 @@ public override void OnImportAsset(AssetImportContext ctx)
ctx.SetMainObject(gsplatAsset);

#if GSPLAT_VERBOSE_IMPORT_LOGGING
LogImportStats(ctx.assetPath, gsplatAsset, isSpz, spzTimings, swTotal!.ElapsedMilliseconds);
LogImportStats(ctx.assetPath, gsplatAsset, isSpz || isSog, importTimings, swTotal!.ElapsedMilliseconds);
#endif
}

Expand All @@ -108,6 +138,20 @@ static string GetCachePath(string assetPath, CompressionMode compression, Source
return Path.Combine("Library", "GsplatCache", key + ".bin");
}

static string GetSogCachePath(string assetPath, CompressionMode compression, SourceCoordinates sourceCoordinates,
string decoderVersion)
{
var fi = new FileInfo(assetPath);
string stem = Path.GetFileNameWithoutExtension(assetPath);
string ext = Path.GetExtension(assetPath).TrimStart('.').ToLowerInvariant();
string key = $"{stem}_{ext}_{fi.Length}_{fi.LastWriteTimeUtc.Ticks}_{compression}_{sourceCoordinates}_{decoderVersion}";
key = string.Join("_", key.Split(Path.GetInvalidFileNameChars()));
return Path.Combine("Library", "GsplatCache", key + ".bin");
}

static bool IsSpz(string path) => path.EndsWith(".spz", StringComparison.OrdinalIgnoreCase);
static bool IsSog(string path) => path.EndsWith(".sog", StringComparison.OrdinalIgnoreCase);

static void LogImportStats(string assetPath, GsplatAsset asset, bool isSpz,
SpzPhaseTimings spzTimings, long totalMs)
{
Expand Down Expand Up @@ -135,7 +179,8 @@ static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAsse
var reimported = new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var p in importedAssets)
if (p.EndsWith(".ply", StringComparison.OrdinalIgnoreCase) ||
p.EndsWith(".spz", StringComparison.OrdinalIgnoreCase))
p.EndsWith(".spz", StringComparison.OrdinalIgnoreCase) ||
p.EndsWith(".sog", StringComparison.OrdinalIgnoreCase))
reimported.Add(p);

if (reimported.Count == 0) return;
Expand All @@ -157,4 +202,4 @@ static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAsse
}
}
}
}
}
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Most 3DGS assets are trained in Gamma space, following the official implementati

- Supports SPZ versions 1-4 with SH degrees 0-4

- Supports PlayCanvas SOG v2 ZIP bundles with SH bands 0-3

- Supports orthographic projection

- Compatible with MSAA
Expand All @@ -43,6 +45,18 @@ The sorting pass, built upon [b0nes164/GPUSorting](https://github.com/b0nes164/G

### Install

SOG support depends on `com.netpyoung.webp`, which is distributed through OpenUPM. Before installing Gsplat, add the following scoped registry to your project's `Packages/manifest.json`:

```json
"scopedRegistries": [
{
"name": "OpenUPM",
"url": "https://package.openupm.com",
"scopes": ["com.netpyoung.webp"]
}
]
```

After cloning or downloading this repository, open your Unity project (or create a new one). Navigate to `Window > Package Manager`, click the `+` button, select `Install package from disk...`, and then choose the `package.json` file from this repository.

### Setup
Expand All @@ -61,7 +75,11 @@ The next steps depend on the Render Pipeline you are using:

### Import Assets

Copy or drag & drop the PLY file anywhere into your project's `Assets` folder. The package will then automatically read the file and import it as a derived class of `Gsplat Asset`. The package supports two compression modes for the asset: `Uncompressed` and `Spark` (packed). The default mode is `Spark`, which is inspired by [spark.js](https://github.com/sparkjsdev/spark). You can change the compression mode in the inspector of the imported `Gsplat Asset`.
Copy or drag & drop a PLY, SPZ, or SOG file anywhere into your project's `Assets` folder. The package will then automatically read the file and import it as a derived class of `Gsplat Asset`. The package supports two compression modes for the asset: `Uncompressed` and `Spark` (packed). The default mode is `Spark`, which is inspired by [spark.js](https://github.com/sparkjsdev/spark). You can change the compression mode in the inspector of the imported `Gsplat Asset`.

SOG import supports PlayCanvas SOG version 2 bundled `.sog` ZIP files containing `meta.json` and lossless WebP property images. Directory-style SOG datasets are not imported directly. SOG WebP decoding uses [`unity.webp`](https://github.com/netpyoung/unity.webp)'s native libwebp bindings, so the same decoder path is available for editor import and runtime loading on supported platforms including Android.

For runtime SOG loading, create a `GsplatAssetSog` or `GsplatAssetSogUncompressed` instance and call `LoadFromSog`, then assign it to a `GsplatRenderer`. Runtime SOG loading performs ZIP, JSON, WebP decode, and splat packing on-device; after loading, rendering uses the same Spark or Uncompressed pipeline as imported assets.

### Add Gsplat Renderer

Expand Down
5 changes: 3 additions & 2 deletions Runtime/Gsplat.asmdef
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"GUID:15fc0a57446b3144c949da3e2b9737a9",
"GUID:457756d89b35d2941b3e7b37b4ece6f1",
"GUID:d8b63aba1907145bea998dd612889d6b",
"Gsplat.ZstdSharp"
"Gsplat.ZstdSharp",
"unity.webp"
],
"includePlatforms": [],
"excludePlatforms": [],
Expand All @@ -28,4 +29,4 @@
}
],
"noEngineReferences": false
}
}
2 changes: 1 addition & 1 deletion Runtime/GsplatAsset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public enum CompressionMode
/// </summary>
public enum SourceCoordinates
{
[InspectorName("Unspecified (treated as RUB)")]
[InspectorName("Unspecified / format default")]
Unspecified = 0,

[InspectorName("LDB — Left-Down-Back")]
Expand Down
148 changes: 148 additions & 0 deletions Runtime/GsplatAssetSog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) 2026
// SPDX-License-Identifier: MIT

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

namespace Gsplat
{
// Loads SOG files and stores them in Spark-compressed format.
public class GsplatAssetSog : GsplatAssetSpark
{
const int ProgressStride = 65536;

readonly struct DecodeContext
{
public readonly SogData Data;
public readonly float PosXSign, PosYSign, PosZSign;
public readonly float RotXSign, RotYSign, RotZSign;
public readonly SourceCoordinates SrcCoords;

public DecodeContext(SogData data, SourceCoordinates srcCoords)
{
Data = data;
SrcCoords = srcCoords;
(PosXSign, PosYSign, PosZSign) = GsplatUtils.AxisSigns(srcCoords);
RotXSign = PosYSign * PosZSign;
RotYSign = PosXSign * PosZSign;
RotZSign = PosXSign * PosYSign;
}
}

public override void LoadFromPly(string plyPath, ProgressCallback progressCallback = null,
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF)
=> throw new NotSupportedException("GsplatAssetSog loads SOG files, not PLY.");

public SpzPhaseTimings LoadFromSog(string sogPath,
SourceCoordinates sourceCoordinates = SourceCoordinates.RDB,
ProgressCallback progressCallback = null)
{
var swDecode = Stopwatch.StartNew();
var data = SogLoader.Load(sogPath);
swDecode.Stop();

SplatCount = (uint)data.Count;
SHBands = (byte)data.ShBands;
Allocate();

int splatCount = data.Count;
var ctx = new DecodeContext(data, sourceCoordinates);
var tlShBand = new ThreadLocal<float[]>(() => new float[7 * 3]);

var gMin = Vector3.positiveInfinity;
var gMax = Vector3.negativeInfinity;
var boundsLock = new object();
long processedCount = 0;
const int progressMask = ProgressStride - 1;

var swPack = Stopwatch.StartNew();
var packTask = Task.Run(() =>
Parallel.For(
0, splatCount,
() => (min: Vector3.positiveInfinity, max: Vector3.negativeInfinity),
(i, _, localBounds) =>
{
var position = DecodeSplatIntoPackedArrays(i, in ctx, tlShBand.Value);
localBounds.min = Vector3.Min(localBounds.min, position);
localBounds.max = Vector3.Max(localBounds.max, position);

if ((i & progressMask) == 0)
Interlocked.Add(ref processedCount, ProgressStride);

return localBounds;
},
localBounds =>
{
lock (boundsLock)
{
gMin = Vector3.Min(gMin, localBounds.min);
gMax = Vector3.Max(gMax, localBounds.max);
}
}));

while (!packTask.IsCompleted)
{
if (progressCallback != null)
{
float p = splatCount == 0 ? 1f : Math.Min(1f, Interlocked.Read(ref processedCount) / (float)splatCount);
progressCallback("Packing SOG splats", p);
}
Thread.Sleep(100);
}

packTask.GetAwaiter().GetResult();
swPack.Stop();
tlShBand.Dispose();

if (SplatCount > 0)
Bounds = new Bounds((gMin + gMax) * 0.5f, gMax - gMin);

progressCallback?.Invoke("Packing SOG splats", 1f);

return new SpzPhaseTimings
{
DecompressMs = swDecode.ElapsedMilliseconds,
PackMs = swPack.ElapsedMilliseconds,
};
}

Vector3 DecodeSplatIntoPackedArrays(int i, in DecodeContext ctx, float[] shBandData)
{
var rawPos = SogLoader.DecodePosition(ctx.Data, i);
var position = new Vector3(ctx.PosXSign * rawPos.x, ctx.PosYSign * rawPos.y, ctx.PosZSign * rawPos.z);

var color = SogLoader.DecodeColorLogitAlpha(ctx.Data, i);
var scale = SogLoader.DecodeScaleLog(ctx.Data, i);
var rawRot = SogLoader.DecodeRotation(ctx.Data, i);
var rotation = new Quaternion(
rawRot.w,
ctx.RotXSign * rawRot.x,
ctx.RotYSign * rawRot.y,
ctx.RotZSign * rawRot.z);

PackedSplats[i] = PackSplat(color, position, scale, rotation);

for (int band = 1; band <= SHBands; band++)
{
SogLoader.DecodeShBand(ctx.Data, i, band, shBandData);
int bandSize = band * 2 + 1;
for (int k = 0; k < bandSize; k++)
{
float sign = GsplatUtils.ShSign(ctx.SrcCoords, band, k);
shBandData[k * 3 + 0] *= sign;
shBandData[k * 3 + 1] *= sign;
shBandData[k * 3 + 2] *= sign;
}

if (band == 1) PackSH1(shBandData, PackedSH1.AsSpan(i * 2, 2));
if (band == 2) PackSH2(shBandData, PackedSH2.AsSpan(i * 4, 4));
if (band == 3) PackSH3(shBandData, PackedSH3.AsSpan(i * 4, 4));
}

return position;
}
}
}
11 changes: 11 additions & 0 deletions Runtime/GsplatAssetSog.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading