Skip to content
Merged
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
2 changes: 2 additions & 0 deletions 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 an `OpacityPruneThreshold` option to `GsplatImporter`. Splats with opacity below this threshold are culled at import time. Currently only supported for `.ply` import. `LoadFromPly` / `LoadFromPlyBytes` take an optional `opacityPruneThreshold` parameter, so runtime byte-array loading can prune as well. ([#36](https://github.com/wuyize25/gsplat-unity/pull/36) by [@TakashiYoshinaga](https://github.com/TakashiYoshinaga))

### 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
34 changes: 28 additions & 6 deletions Editor/GsplatImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ public class GsplatImporter : ScriptedImporter
{
public CompressionMode Compression = CompressionMode.Spark;

[Tooltip("Removes splats whose opacity (after sigmoid) is below this value at import time. 0 disables pruning.\n" +
"Start around 0.01–0.05 and increase while checking the visual result. Reduces overdraw, memory usage, and load time.\n" +
"Currently only supported for .ply import.")]
[Range(0f, 0.99f)]
public float OpacityPruneThreshold = 0f;

[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" +
Expand Down Expand Up @@ -48,13 +54,17 @@ public override void OnImportAsset(AssetImportContext ctx)
ProgressCallback progress = (info, p) => EditorUtility.DisplayProgressBar(
"Importing Gsplat Asset", info, p);

if (isSpz && OpacityPruneThreshold > 0f)
UnityEngine.Debug.LogWarning(
$"[Gsplat Import] {ctx.assetPath}: OpacityPruneThreshold is currently only supported for .ply import (ignored for .spz)");

if (gsplatAsset is GsplatAssetSpzUncompressed spzUncompressedAsset)
{
spzTimings = 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, OpacityPruneThreshold);
if (!spzAsset.TryLoadFromCache(cachePath))
{
spzTimings = spzAsset.LoadFromSpz(ctx.assetPath, SourceCoordinates, progress);
Expand All @@ -70,7 +80,7 @@ public override void OnImportAsset(AssetImportContext ctx)
}
else
{
gsplatAsset.LoadFromPly(ctx.assetPath, progress, SourceCoordinates);
gsplatAsset.LoadFromPly(ctx.assetPath, progress, SourceCoordinates, OpacityPruneThreshold);
}
}
catch (Exception e)
Expand All @@ -89,6 +99,15 @@ public override void OnImportAsset(AssetImportContext ctx)
swTotal?.Stop();
}

if (gsplatAsset.PrunedSplatCount > 0)
{
var removed = gsplatAsset.PrunedSplatCount;
var sourceSplatCount = gsplatAsset.SplatCount + gsplatAsset.PrunedSplatCount;
UnityEngine.Debug.Log(
$"[Gsplat Import] {Path.GetFileName(ctx.assetPath)}: pruned {removed:N0} / {sourceSplatCount:N0} splats " +
$"({removed * 100.0 / sourceSplatCount:F1}%) below opacity {OpacityPruneThreshold}");
}

ctx.AddObjectToAsset("gsplatAsset", gsplatAsset);
ctx.SetMainObject(gsplatAsset);

Expand All @@ -97,13 +116,16 @@ public override void OnImportAsset(AssetImportContext ctx)
#endif
}

// Cache key combines filename, file size, last-write time, compression mode, and
// source coordinate frame so any change in source file or import settings busts the cache.
static string GetCachePath(string assetPath, CompressionMode compression, SourceCoordinates sourceCoordinates)
// Cache key combines filename, file size, last-write time, compression mode, source
// coordinate frame, and prune threshold so any change in source file or import settings busts the cache.
static string GetCachePath(string assetPath, CompressionMode compression, SourceCoordinates sourceCoordinates,
float opacityPruneThreshold)
{
var fi = new FileInfo(assetPath);
string stem = Path.GetFileNameWithoutExtension(assetPath);
string key = $"{stem}_{fi.Length}_{fi.LastWriteTimeUtc.Ticks}_{compression}_{sourceCoordinates}";
string key =
$"{stem}_{fi.Length}_{fi.LastWriteTimeUtc.Ticks}_{compression}_{sourceCoordinates}_" +
opacityPruneThreshold.ToString("R", System.Globalization.CultureInfo.InvariantCulture);
key = string.Join("_", key.Split(Path.GetInvalidFileNameChars()));
return Path.Combine("Library", "GsplatCache", key + ".bin");
}
Expand Down
9 changes: 8 additions & 1 deletion Editor/GsplatImporterEditor.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 Niantic Spatial
// SPDX-License-Identifier: MIT

using System.IO;
using UnityEditor;
using UnityEditor.AssetImporters;

Expand All @@ -16,8 +17,14 @@ public override void OnInspectorGUI()
EditorGUILayout.PropertyField(serializedObject.FindProperty("Compression"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("SourceCoordinates"));

var importer = target as GsplatImporter;
var assetPath = importer ? importer.assetPath : string.Empty;
var ext = Path.GetExtension(assetPath).ToLowerInvariant();
if (ext == ".ply")
EditorGUILayout.PropertyField(serializedObject.FindProperty("OpacityPruneThreshold"));

serializedObject.ApplyModifiedProperties();
ApplyRevertGUI();
}
}
}
}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ The next steps depend on the Render Pipeline you are using:

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`.

The `Opacity Prune Threshold` import option removes splats whose opacity (after sigmoid) is below the threshold at import time. Since overdraw of many overlapping translucent splats is the main rendering cost of Gaussian Splatting, pruning nearly transparent splats can significantly improve frame rate, and also reduces memory usage and load time. Set the slider in the inspector of the imported asset and press `Apply` to reimport; the Console then logs how many splats were pruned (e.g. `[Gsplat Import] scene.ply: pruned N / M splats (x.x%)`). Start around `0.01`–`0.05` and increase while checking the visual result (thin fog-like details disappear first — as a reference, a threshold of `0.05` removed about 40% of the splats on one of our scans with little visible change). Setting it back to `0` and reimporting fully restores the original data. Currently only supported for `.ply` import.

### Add Gsplat Renderer

Create or choose a game object in your scene, and add the `Gsplat Renderer` component on it. Point the `Gsplat Asset` field to one of your imported Gsplat Assets. Then it should appear in the viewport.
Expand Down
6 changes: 4 additions & 2 deletions Runtime/GsplatAsset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ public PlyHeaderInfo(Stream fs)
public abstract class GsplatAsset : ScriptableObject
{
public uint SplatCount;
// Pruned splat count during import time; SplatCount + PrunedSplatCount = splat count of the source file.
public uint PrunedSplatCount;
public byte SHBands; // 0, 1, 2, 3, or 4
public Bounds Bounds;
public abstract CompressionMode Compression { get; }
Expand All @@ -154,10 +156,10 @@ public abstract class GsplatAsset : ScriptableObject

public abstract void Allocate();
public abstract void LoadFromPly(string plyPath, ProgressCallback progressCallback = null,
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF);
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF, float opacityPruneThreshold = 0f);

public virtual void LoadFromPlyBytes(byte[] plyBytes, ProgressCallback progressCallback = null,
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF)
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF, float opacityPruneThreshold = 0f)
=> throw new NotSupportedException($"{GetType().Name} does not support loading PLY from bytes.");

public abstract GsplatResource CreateResource();
Expand Down
50 changes: 38 additions & 12 deletions Runtime/GsplatAssetSpark.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,30 +163,32 @@ public override void InitOrder(ISorterResource sorterResource, GsplatResource re
}

public override void LoadFromPly(string plyPath, ProgressCallback progressCallback = null,
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF)
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF, float opacityPruneThreshold = 0f)
{
using var fs = new FileStream(plyPath, FileMode.Open, FileAccess.Read);
// C# arrays and NativeArrays make it hard to have a "byte" array larger than 2GB :/
if (fs.Length >= 2 * 1024 * 1024 * 1024L)
throw new NotSupportedException("currently files larger than 2GB are not supported");

LoadFromPlyStream(fs, progressCallback, sourceCoordinates);
LoadFromPlyStream(fs, progressCallback, sourceCoordinates, opacityPruneThreshold);
}

public override void LoadFromPlyBytes(byte[] plyBytes, ProgressCallback progressCallback = null,
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF)
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF, float opacityPruneThreshold = 0f)
{
if (plyBytes == null || plyBytes.Length == 0)
throw new ArgumentException("PLY byte array is null or empty.", nameof(plyBytes));
using var ms = new MemoryStream(plyBytes, writable: false);
LoadFromPlyStream(ms, progressCallback, sourceCoordinates);
LoadFromPlyStream(ms, progressCallback, sourceCoordinates, opacityPruneThreshold);
}

void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoordinates sourceCoordinates)
void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoordinates sourceCoordinates,
float opacityPruneThreshold)
{
var plyInfo = new PlyHeaderInfo(fs);
var shCoeffs = plyInfo.SHPropertyCount / 3;
SplatCount = plyInfo.VertexCount;
var sourceSplatCount = plyInfo.VertexCount;
SHBands = GsplatUtils.CalcSHBandsFromSHPropertyCount(plyInfo.SHPropertyCount);

if (SHBands > 4 || GsplatUtils.SHBandsToCoefficientCount(SHBands) * 3 != plyInfo.SHPropertyCount)
Expand All @@ -206,13 +208,18 @@ void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoord
Allocate();
var buffer = new byte[plyInfo.PropertyCount * sizeof(float)];
var shBandData = new float[9 * 3]; // max band 4: 9 coeffs × 3 channels; reused each splat
uint w = 0; // write index; splats below the prune threshold are skipped
for (uint i = 0; i < plyInfo.VertexCount; i++)
{
var readBytes = fs.Read(buffer);
if (readBytes != buffer.Length)
throw new EndOfStreamException($"unexpected end of file, got {readBytes} bytes at vertex {i}");

var properties = MemoryMarshal.Cast<byte, float>(buffer);
progressCallback?.Invoke("Reading vertices", i / (float)plyInfo.VertexCount);

if (GsplatUtils.Sigmoid(properties[plyInfo.OpacityOffset]) < opacityPruneThreshold)
continue;

for (int j = 1, shReadOffset = 0; j <= SHBands; j++)
{
Expand All @@ -225,10 +232,10 @@ void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoord
shBandData[k * 3 + 2] = sign * properties[shReadOffset + k + plyInfo.SHOffset + shCoeffs * 2];
}

if (j == 1) PackSH1(shBandData, PackedSH1.AsSpan((int)i * 2, 2));
if (j == 2) PackSH2(shBandData, PackedSH2.AsSpan((int)i * 4, 4));
if (j == 3) PackSH3(shBandData, PackedSH3.AsSpan((int)i * 4, 4));
if (j == 4) PackSH4(shBandData, PackedSH4.AsSpan((int)i * 4, 4));
if (j == 1) PackSH1(shBandData, PackedSH1.AsSpan((int)w * 2, 2));
if (j == 2) PackSH2(shBandData, PackedSH2.AsSpan((int)w * 4, 4));
if (j == 3) PackSH3(shBandData, PackedSH3.AsSpan((int)w * 4, 4));
if (j == 4) PackSH4(shBandData, PackedSH4.AsSpan((int)w * 4, 4));

shReadOffset += bandSize;
}
Expand All @@ -244,7 +251,7 @@ void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoord
posYSign * properties[plyInfo.PositionOffset + 1],
posZSign * properties[plyInfo.PositionOffset + 2]);

if (i == 0) Bounds = new Bounds(position, Vector3.zero);
if (w == 0) Bounds = new Bounds(position, Vector3.zero);
else Bounds.Encapsulate(position);

var scale = new Vector3(
Expand All @@ -258,8 +265,27 @@ void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoord
rotYSign * properties[plyInfo.RotationOffset + 2],
rotZSign * properties[plyInfo.RotationOffset + 3]);

PackedSplats[i] = PackSplat(color, position, scale, rotation);
progressCallback?.Invoke("Reading vertices", i / (float)plyInfo.VertexCount);
PackedSplats[w] = PackSplat(color, position, scale, rotation);
w++;
}

if (w == 0 && plyInfo.VertexCount > 0)
throw new NotSupportedException(
$"opacity prune threshold {opacityPruneThreshold} removed all {plyInfo.VertexCount} splats");

if (w != SplatCount)
{
SplatCount = w;
PrunedSplatCount = sourceSplatCount - SplatCount;
Array.Resize(ref PackedSplats, (int)w);
if (SHBands >= 1)
Array.Resize(ref PackedSH1, (int)w * 2);
if (SHBands >= 2)
Array.Resize(ref PackedSH2, (int)w * 4);
if (SHBands >= 3)
Array.Resize(ref PackedSH3, (int)w * 4);
if (SHBands >= 4)
Array.Resize(ref PackedSH4, (int)w * 4);
}
}

Expand Down
4 changes: 2 additions & 2 deletions Runtime/GsplatAssetSpz.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ public DecodeContext(SpzData data, SourceCoordinates srcCoords, int shBands)
}

public override void LoadFromPly(string plyPath, ProgressCallback progressCallback = null,
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF)
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF, float opacityPruneThreshold = 0f)
=> throw new NotSupportedException("GsplatAssetSpz loads SPZ files, not PLY.");

public override void LoadFromPlyBytes(byte[] plyBytes, ProgressCallback progressCallback = null,
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF)
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF, float opacityPruneThreshold = 0f)
=> throw new NotSupportedException("GsplatAssetSpz loads SPZ files, not PLY.");

public SpzPhaseTimings LoadFromSpz(string spzPath,
Expand Down
4 changes: 2 additions & 2 deletions Runtime/GsplatAssetSpzUncompressed.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ namespace Gsplat
public class GsplatAssetSpzUncompressed : GsplatAssetUncompressed
{
public override void LoadFromPly(string plyPath, ProgressCallback progressCallback = null,
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF)
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF, float opacityPruneThreshold = 0f)
=> throw new System.NotSupportedException("GsplatAssetSpzUncompressed loads SPZ files, not PLY.");

public override void LoadFromPlyBytes(byte[] plyBytes, ProgressCallback progressCallback = null,
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF)
SourceCoordinates sourceCoordinates = SourceCoordinates.RUF, float opacityPruneThreshold = 0f)
=> throw new System.NotSupportedException("GsplatAssetSpzUncompressed loads SPZ files, not PLY.");

public SpzPhaseTimings LoadFromSpz(string spzPath,
Expand Down
Loading