diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d4700..ce08b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) @@ -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 \ No newline at end of file +[1.0.0]: https://github.com/wuyize25/gsplat-unity/releases/tag/v1.0.0 diff --git a/Editor/GsplatImporter.cs b/Editor/GsplatImporter.cs index 212fc67..2188f41 100644 --- a/Editor/GsplatImporter.cs +++ b/Editor/GsplatImporter.cs @@ -10,7 +10,7 @@ namespace Gsplat.Editor { - [ScriptedImporter(1, new[] { "ply", "spz" })] + [ScriptedImporter(1, new[] { "ply", "spz", "sog" })] public class GsplatImporter : ScriptedImporter { public CompressionMode Compression = CompressionMode.Spark; @@ -18,22 +18,31 @@ public class GsplatImporter : ScriptedImporter [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() - : ScriptableObject.CreateInstance(), - CompressionMode.Spark => isSpz - ? ScriptableObject.CreateInstance() - : ScriptableObject.CreateInstance(), + CompressionMode.Uncompressed => isSog + ? ScriptableObject.CreateInstance() + : isSpz + ? ScriptableObject.CreateInstance() + : ScriptableObject.CreateInstance(), + CompressionMode.Spark => isSog + ? ScriptableObject.CreateInstance() + : isSpz + ? ScriptableObject.CreateInstance() + : ScriptableObject.CreateInstance(), _ => throw new ArgumentOutOfRangeException() }; @@ -42,7 +51,7 @@ public override void OnImportAsset(AssetImportContext ctx) #else Stopwatch swTotal = null; #endif - SpzPhaseTimings spzTimings = default; + SpzPhaseTimings importTimings = default; try { ProgressCallback progress = (info, p) => EditorUtility.DisplayProgressBar( @@ -50,14 +59,14 @@ public override void OnImportAsset(AssetImportContext ctx) 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); @@ -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) @@ -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 } @@ -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) { @@ -135,7 +179,8 @@ static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAsse var reimported = new System.Collections.Generic.HashSet(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; @@ -157,4 +202,4 @@ static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAsse } } } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 69e4ccb..c69974e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/Runtime/Gsplat.asmdef b/Runtime/Gsplat.asmdef index f251921..9731b0d 100644 --- a/Runtime/Gsplat.asmdef +++ b/Runtime/Gsplat.asmdef @@ -6,7 +6,8 @@ "GUID:15fc0a57446b3144c949da3e2b9737a9", "GUID:457756d89b35d2941b3e7b37b4ece6f1", "GUID:d8b63aba1907145bea998dd612889d6b", - "Gsplat.ZstdSharp" + "Gsplat.ZstdSharp", + "unity.webp" ], "includePlatforms": [], "excludePlatforms": [], @@ -28,4 +29,4 @@ } ], "noEngineReferences": false -} \ No newline at end of file +} diff --git a/Runtime/GsplatAsset.cs b/Runtime/GsplatAsset.cs index fdd445e..8196f27 100644 --- a/Runtime/GsplatAsset.cs +++ b/Runtime/GsplatAsset.cs @@ -26,7 +26,7 @@ public enum CompressionMode /// public enum SourceCoordinates { - [InspectorName("Unspecified (treated as RUB)")] + [InspectorName("Unspecified / format default")] Unspecified = 0, [InspectorName("LDB — Left-Down-Back")] diff --git a/Runtime/GsplatAssetSog.cs b/Runtime/GsplatAssetSog.cs new file mode 100644 index 0000000..96ab39e --- /dev/null +++ b/Runtime/GsplatAssetSog.cs @@ -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(() => 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; + } + } +} diff --git a/Runtime/GsplatAssetSog.cs.meta b/Runtime/GsplatAssetSog.cs.meta new file mode 100644 index 0000000..a1298ca --- /dev/null +++ b/Runtime/GsplatAssetSog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d0cb6a3d4184f5a8d9213176ae5de96 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GsplatAssetSogUncompressed.cs b/Runtime/GsplatAssetSogUncompressed.cs new file mode 100644 index 0000000..b1ffb8e --- /dev/null +++ b/Runtime/GsplatAssetSogUncompressed.cs @@ -0,0 +1,89 @@ +// Copyright (c) 2026 +// SPDX-License-Identifier: MIT + +using System.Diagnostics; +using UnityEngine; + +namespace Gsplat +{ + // Loads SOG files into GsplatAssetUncompressed. + public class GsplatAssetSogUncompressed : GsplatAssetUncompressed + { + public override void LoadFromPly(string plyPath, ProgressCallback progressCallback = null, + SourceCoordinates sourceCoordinates = SourceCoordinates.RUF) + => throw new System.NotSupportedException("GsplatAssetSogUncompressed 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(); + + var (posXSign, posYSign, posZSign) = GsplatUtils.AxisSigns(sourceCoordinates); + float rotXSign = posYSign * posZSign; + float rotYSign = posXSign * posZSign; + float rotZSign = posXSign * posYSign; + int shDim = data.ShCoefficientCount; + int splatCount = data.Count; + var shBandData = new float[7 * 3]; + + var swPack = Stopwatch.StartNew(); + for (int i = 0; i < splatCount; i++) + { + var rawPos = SogLoader.DecodePosition(data, i); + Positions[i] = new Vector3(posXSign * rawPos.x, posYSign * rawPos.y, posZSign * rawPos.z); + + if (i == 0) Bounds = new Bounds(Positions[i], Vector3.zero); + else Bounds.Encapsulate(Positions[i]); + + Colors[i] = SogLoader.DecodeColorLinearAlpha(data, i); + + var logScale = SogLoader.DecodeScaleLog(data, i); + Scales[i] = new Vector3( + Mathf.Exp(logScale.x), + Mathf.Exp(logScale.y), + Mathf.Exp(logScale.z)); + + var rawRot = SogLoader.DecodeRotation(data, i); + Rotations[i] = new Vector4( + rawRot.w, + rotXSign * rawRot.x, + rotYSign * rawRot.y, + rotZSign * rawRot.z).normalized; + + for (int band = 1, bandOffset = 0; band <= SHBands; band++) + { + int bandSize = band * 2 + 1; + SogLoader.DecodeShBand(data, i, band, shBandData); + for (int k = 0; k < bandSize; k++) + { + float sign = GsplatUtils.ShSign(sourceCoordinates, band, k); + SHs[i * shDim + bandOffset + k] = sign * new Vector3( + shBandData[k * 3 + 0], + shBandData[k * 3 + 1], + shBandData[k * 3 + 2]); + } + bandOffset += bandSize; + } + + if ((i & 0xFFFF) == 0) + progressCallback?.Invoke("Reading SOG splats", splatCount == 0 ? 1f : i / (float)splatCount); + } + swPack.Stop(); + + progressCallback?.Invoke("Reading SOG splats", 1f); + + return new SpzPhaseTimings + { + DecompressMs = swDecode.ElapsedMilliseconds, + PackMs = swPack.ElapsedMilliseconds, + }; + } + } +} diff --git a/Runtime/GsplatAssetSogUncompressed.cs.meta b/Runtime/GsplatAssetSogUncompressed.cs.meta new file mode 100644 index 0000000..af9d2dc --- /dev/null +++ b/Runtime/GsplatAssetSogUncompressed.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 00f38ccf30df47c9a1433168ad6b47bd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/SogImageDecoder.cs b/Runtime/SogImageDecoder.cs new file mode 100644 index 0000000..eb33446 --- /dev/null +++ b/Runtime/SogImageDecoder.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2026 +// SPDX-License-Identifier: MIT + +using System; +using System.IO; +using WebP; +using UnityEngine; + +namespace Gsplat +{ + internal readonly struct SogImage + { + public readonly int Width; + public readonly int Height; + public readonly Color32[] Pixels; + + public SogImage(int width, int height, Color32[] pixels) + { + Width = width; + Height = height; + Pixels = pixels; + } + + public int PixelCount => Width * Height; + + public Color32 this[int index] => Pixel(index % Width, index / Width); + + public Color32 Pixel(int x, int y) => Pixels[x + (Height - 1 - y) * Width]; + } + + public static class SogImageDecoder + { + public const string DecoderVersion = "unity-webp-rgba32-rot-wxyz-v1"; + + internal static SogImage Decode(byte[] imageBytes, string name) + { + if (imageBytes == null || imageBytes.Length == 0) + throw new InvalidDataException($"SOG: image '{name}' is empty."); + + try + { + Texture2DExt.GetWebPDimensions(imageBytes, out int width, out int height); + var pixelsRgba = Texture2DExt.LoadRGBAFromWebP(imageBytes, ref width, ref height, false, out var error); + if (error != Error.Success) + throw new InvalidDataException($"SOG: failed to decode image '{name}' with libwebp: {error}."); + if (pixelsRgba == null || pixelsRgba.Length != width * height * 4) + throw new InvalidDataException($"SOG: decoded image '{name}' has inconsistent pixel data."); + + var pixels = new Color32[width * height]; + for (int i = 0, j = 0; i < pixels.Length; i++, j += 4) + { + pixels[i] = new Color32( + pixelsRgba[j], + pixelsRgba[j + 1], + pixelsRgba[j + 2], + pixelsRgba[j + 3]); + } + + return new SogImage(width, height, pixels); + } + catch (Exception e) when (!(e is InvalidDataException)) + { + throw new InvalidDataException($"SOG: failed to decode image '{name}': {e.Message}", e); + } + } + } +} diff --git a/Runtime/SogImageDecoder.cs.meta b/Runtime/SogImageDecoder.cs.meta new file mode 100644 index 0000000..c58263e --- /dev/null +++ b/Runtime/SogImageDecoder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d6ab2191a6bb4b58a82671b8c17f35fb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/SogLoader.cs b/Runtime/SogLoader.cs new file mode 100644 index 0000000..de54239 --- /dev/null +++ b/Runtime/SogLoader.cs @@ -0,0 +1,353 @@ +// Copyright (c) 2026 +// SPDX-License-Identifier: MIT + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using UnityEngine; + +namespace Gsplat +{ + internal sealed class SogData + { + public SogMeta Meta; + public SogImage MeansL; + public SogImage MeansU; + public SogImage Scales; + public SogImage Quats; + public SogImage Sh0; + public SogImage ShNCentroids; + public SogImage ShNLabels; + + public bool HasShN => SogLoader.HasShN(Meta); + public int Count => Meta.Count; + public int ShBands => HasShN ? Meta.ShN.Bands : 0; + public int ShCoefficientCount => HasShN ? GsplatUtils.SHBandsToCoefficientCount((byte)Meta.ShN.Bands) : 0; + } + + [Serializable] + internal sealed class SogMeta + { + public int version; + public int count; + public SogMetaMeans means; + public SogMetaCodebook scales; + public SogMetaQuats quats; + public SogMetaCodebook sh0; + public SogMetaShN shN; + + public int Version => version; + public int Count => count; + public SogMetaMeans Means => means; + public SogMetaCodebook Scales => scales; + public SogMetaQuats Quats => quats; + public SogMetaCodebook Sh0 => sh0; + public SogMetaShN ShN => shN; + } + + [Serializable] + internal sealed class SogMetaMeans + { + public float[] mins; + public float[] maxs; + public string[] files; + } + + [Serializable] + internal sealed class SogMetaCodebook + { + public float[] codebook; + public string[] files; + } + + [Serializable] + internal sealed class SogMetaQuats + { + public string[] files; + } + + [Serializable] + internal sealed class SogMetaShN + { + public int bands; + public int count; + public float[] codebook; + public string[] files; + + public int Bands => bands; + public int Count => count; + } + + internal static class SogLoader + { + const int SupportedVersion = 2; + const int CodebookSize = 256; + const float Sqrt1_2 = 0.70710678118f; + + public static SogData Load(string path) + { + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read); + using var zip = new ZipArchive(fs, ZipArchiveMode.Read); + return LoadZip(zip, Path.GetFileName(path)); + } + catch (InvalidDataException) + { + throw; + } + catch (NotSupportedException) + { + throw; + } + catch (Exception e) + { + throw new InvalidDataException($"SOG: failed to read '{Path.GetFileName(path)}' as a ZIP bundle: {e.Message}", e); + } + } + + static SogData LoadZip(ZipArchive zip, string displayName) + { + var entries = new Dictionary(StringComparer.Ordinal); + foreach (var entry in zip.Entries) + { + var name = entry.FullName.Replace('\\', '/'); + if (!name.Contains("/") && !string.IsNullOrEmpty(name)) + entries[name] = entry; + } + + if (!entries.TryGetValue("meta.json", out var metaEntry)) + throw new InvalidDataException($"SOG: '{displayName}' is missing root entry 'meta.json'."); + + var metaJson = ReadEntryText(metaEntry); + var meta = JsonUtility.FromJson(metaJson); + ValidateMeta(meta); + + var data = new SogData + { + Meta = meta, + MeansL = ReadImage(entries, meta.Means.files[0]), + MeansU = ReadImage(entries, meta.Means.files[1]), + Scales = ReadImage(entries, meta.Scales.files[0]), + Quats = ReadImage(entries, meta.Quats.files[0]), + Sh0 = ReadImage(entries, meta.Sh0.files[0]), + }; + + if (data.HasShN) + { + data.ShNCentroids = ReadImage(entries, meta.ShN.files[0]); + data.ShNLabels = ReadImage(entries, meta.ShN.files[1]); + } + + ValidateImages(data); + return data; + } + + static string ReadEntryText(ZipArchiveEntry entry) + { + using var stream = entry.Open(); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + static SogImage ReadImage(Dictionary entries, string name) + { + if (!entries.TryGetValue(name, out var entry)) + throw new InvalidDataException($"SOG: missing image '{name}'."); + + using var stream = entry.Open(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return SogImageDecoder.Decode(ms.ToArray(), name); + } + + public static bool HasShN(SogMeta meta) + { + var shN = meta?.ShN; + return shN != null && + (shN.Bands != 0 || shN.Count != 0 || shN.codebook != null || shN.files != null); + } + + static void ValidateMeta(SogMeta meta) + { + if (meta == null) + throw new InvalidDataException("SOG: meta.json could not be parsed."); + if (meta.Version != SupportedVersion) + throw new NotSupportedException($"SOG: version {meta.Version} is not supported (expected {SupportedVersion})."); + if (meta.Count < 0) + throw new InvalidDataException($"SOG: count {meta.Count} is invalid."); + if (meta.Means == null) + throw new InvalidDataException("SOG: meta.means is missing."); + ValidateFloatArray(meta.Means.mins, 3, "meta.means.mins"); + ValidateFloatArray(meta.Means.maxs, 3, "meta.means.maxs"); + ValidateFileArray(meta.Means.files, 2, "meta.means.files"); + if (meta.Scales == null) + throw new InvalidDataException("SOG: meta.scales is missing."); + ValidateFloatArray(meta.Scales.codebook, CodebookSize, "meta.scales.codebook"); + ValidateFileArray(meta.Scales.files, 1, "meta.scales.files"); + if (meta.Quats == null) + throw new InvalidDataException("SOG: meta.quats is missing."); + ValidateFileArray(meta.Quats.files, 1, "meta.quats.files"); + if (meta.Sh0 == null) + throw new InvalidDataException("SOG: meta.sh0 is missing."); + ValidateFloatArray(meta.Sh0.codebook, CodebookSize, "meta.sh0.codebook"); + ValidateFileArray(meta.Sh0.files, 1, "meta.sh0.files"); + + if (!HasShN(meta)) + return; + + if (meta.ShN.Bands < 1 || meta.ShN.Bands > 3) + throw new NotSupportedException($"SOG: shN.bands {meta.ShN.Bands} is not supported (expected 1..3)."); + if (meta.ShN.Count < 1 || meta.ShN.Count > 65536) + throw new InvalidDataException($"SOG: shN.count {meta.ShN.Count} is invalid."); + ValidateFloatArray(meta.ShN.codebook, CodebookSize, "meta.shN.codebook"); + ValidateFileArray(meta.ShN.files, 2, "meta.shN.files"); + } + + static void ValidateFloatArray(float[] values, int expectedLength, string name) + { + if (values == null || values.Length != expectedLength) + throw new InvalidDataException($"SOG: {name} must contain {expectedLength} values."); + } + + static void ValidateFileArray(string[] values, int expectedLength, string name) + { + if (values == null || values.Length != expectedLength) + throw new InvalidDataException($"SOG: {name} must contain {expectedLength} filenames."); + for (int i = 0; i < values.Length; i++) + { + if (string.IsNullOrEmpty(values[i])) + throw new InvalidDataException($"SOG: {name}[{i}] is empty."); + if (values[i].Contains("/") || values[i].Contains("\\")) + throw new InvalidDataException($"SOG: bundled imports require root entry filenames; {name}[{i}] is '{values[i]}'."); + } + } + + static void ValidateImages(SogData data) + { + ValidatePropertyImage(data.MeansL, data.Count, data.Meta.Means.files[0]); + ValidatePropertyImage(data.MeansU, data.Count, data.Meta.Means.files[1]); + ValidatePropertyImage(data.Scales, data.Count, data.Meta.Scales.files[0]); + ValidatePropertyImage(data.Quats, data.Count, data.Meta.Quats.files[0]); + ValidatePropertyImage(data.Sh0, data.Count, data.Meta.Sh0.files[0]); + + if (!data.HasShN) + return; + + ValidatePropertyImage(data.ShNLabels, data.Count, data.Meta.ShN.files[1]); + int coeffs = data.ShCoefficientCount; + int requiredCentroidWidth = 64 * coeffs; + int requiredCentroidHeight = (data.Meta.ShN.Count + 63) / 64; + if (data.ShNCentroids.Width < requiredCentroidWidth || data.ShNCentroids.Height < requiredCentroidHeight) + { + throw new InvalidDataException( + $"SOG: {data.Meta.ShN.files[0]} is {data.ShNCentroids.Width}x{data.ShNCentroids.Height}, expected at least {requiredCentroidWidth}x{requiredCentroidHeight}."); + } + } + + static void ValidatePropertyImage(SogImage image, int count, string name) + { + if (image.PixelCount < count) + throw new InvalidDataException($"SOG: image '{name}' has {image.PixelCount} pixels, fewer than meta.count {count}."); + } + + public static Vector3 DecodePosition(SogData data, int i) + { + var lo = data.MeansL[i]; + var hi = data.MeansU[i]; + return new Vector3( + DecodePositionAxis(lo.r, hi.r, data.Meta.Means.mins[0], data.Meta.Means.maxs[0]), + DecodePositionAxis(lo.g, hi.g, data.Meta.Means.mins[1], data.Meta.Means.maxs[1]), + DecodePositionAxis(lo.b, hi.b, data.Meta.Means.mins[2], data.Meta.Means.maxs[2])); + } + + static float DecodePositionAxis(byte lo, byte hi, float min, float max) + { + int v = lo | (hi << 8); + float n = Mathf.Lerp(min, max, v / 65535.0f); + return Mathf.Sign(n) * (Mathf.Exp(Mathf.Abs(n)) - 1.0f); + } + + public static Vector3 DecodeScaleLog(SogData data, int i) + { + var p = data.Scales[i]; + var codebook = data.Meta.Scales.codebook; + return new Vector3(codebook[p.r], codebook[p.g], codebook[p.b]); + } + + public static Vector4 DecodeColorLinearAlpha(SogData data, int i) + { + var p = data.Sh0[i]; + var codebook = data.Meta.Sh0.codebook; + return new Vector4(codebook[p.r], codebook[p.g], codebook[p.b], p.a / 255.0f); + } + + public static Vector4 DecodeColorLogitAlpha(SogData data, int i) + { + var c = DecodeColorLinearAlpha(data, i); + float a = Mathf.Clamp(c.w, 1e-6f, 1f - 1e-6f); + c.w = Mathf.Log(a / (1f - a)); + return c; + } + + public static Quaternion DecodeRotation(SogData data, int i) + { + var p = data.Quats[i]; + int largest = p.a - 252; + if (largest < 0 || largest > 3) + throw new InvalidDataException($"SOG: quats.webp pixel {i} has invalid alpha {p.a}; expected 252..255."); + + float r = DecodeRotationComponent(p.r); + float g = DecodeRotationComponent(p.g); + float b = DecodeRotationComponent(p.b); + float missing = Mathf.Sqrt(Mathf.Max(0f, 1f - r * r - g * g - b * b)); + + float q0, q1, q2, q3; + switch (largest) + { + case 0: (q0, q1, q2, q3) = (missing, r, g, b); break; + case 1: (q0, q1, q2, q3) = (r, missing, g, b); break; + case 2: (q0, q1, q2, q3) = (r, g, missing, b); break; + case 3: (q0, q1, q2, q3) = (r, g, b, missing); break; + default: throw new InvalidOperationException(); + } + + return new Quaternion(q1, q2, q3, q0).normalized; + } + + static float DecodeRotationComponent(byte value) => + ((value / 255.0f) * 2.0f - 1.0f) * Sqrt1_2; + + public static void DecodeShBand(SogData data, int i, int band, float[] output) + { + if (!data.HasShN) + throw new InvalidOperationException("SOG: shN data is not present."); + + int label = DecodeShLabel(data, i); + int coeffBase = GsplatUtils.SHBandsToCoefficientCount((byte)(band - 1)); + int bandSize = band * 2 + 1; + var codebook = data.Meta.ShN.codebook; + + for (int k = 0; k < bandSize; k++) + { + int coeff = coeffBase + k; + int u = (label % 64) * data.ShCoefficientCount + coeff; + int v = label / 64; + var p = data.ShNCentroids.Pixel(u, v); + output[k * 3 + 0] = codebook[p.r]; + output[k * 3 + 1] = codebook[p.g]; + output[k * 3 + 2] = codebook[p.b]; + } + } + + static int DecodeShLabel(SogData data, int i) + { + var p = data.ShNLabels[i]; + int label = p.r | (p.g << 8); + if (label >= data.Meta.ShN.Count) + throw new InvalidDataException($"SOG: shN label {label} at pixel {i} exceeds shN.count {data.Meta.ShN.Count}."); + return label; + } + } +} diff --git a/Runtime/SogLoader.cs.meta b/Runtime/SogLoader.cs.meta new file mode 100644 index 0000000..ed64e9d --- /dev/null +++ b/Runtime/SogLoader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 094b4d88bbfc4f1fb231a5588d29c631 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json index 1128155..d69ac2c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "license": "MIT", "repository": "github:wuyize25/gsplat-unity", "dependencies": { + "com.netpyoung.webp": "0.3.22", "com.unity.mathematics": "1.3.2" } -} \ No newline at end of file +}