From 3788c2aba23d7343d895a7f6735ee053b6b4d614 Mon Sep 17 00:00:00 2001 From: Takashi Yoshinaga Date: Tue, 14 Jul 2026 08:56:06 +0900 Subject: [PATCH 1/3] Add OpacityPruneThreshold to remove low-opacity splats at import time Overdraw from many overlapping translucent splats is the main rendering cost of Gaussian Splatting. Pruning splats whose opacity (after sigmoid) is below a threshold reduces the splat count itself, improving frame rate and also cutting memory usage and load time. As a reference, a threshold of 0.05 removed about 40% of the splats on a real-world scan with little visible change. The default of 0 keeps the previous behavior of loading every splat. - GsplatImporter exposes an Opacity Prune Threshold slider (0-0.99); the importer version is bumped and the threshold is part of the SPZ cache key so changing it triggers a proper reimport - LoadFromPly / LoadFromPlyBytes take an optional opacityPruneThreshold parameter (default 0f), so runtime byte-array loading can prune too - The PLY read loop skips splats below the threshold using a separate write index, then trims the arrays; loading fails with a clear error if the threshold removed every splat - GsplatAsset.SourceSplatCount records the pre-prune count; the importer and the runtime loader test log how many splats were pruned - Currently only supported for .ply import; a warning is logged when a nonzero threshold is set on an .spz asset --- Editor/GsplatImporter.cs | 35 ++++++++++++++---- Editor/GsplatImporterEditor.cs | 1 + README.md | 2 ++ Runtime/GsplatAsset.cs | 6 ++-- Runtime/GsplatAssetSpark.cs | 49 ++++++++++++++++++------- Runtime/GsplatAssetSpz.cs | 4 +-- Runtime/GsplatAssetSpzUncompressed.cs | 4 +-- Runtime/GsplatAssetUncompressed.cs | 51 +++++++++++++++++++-------- Runtime/RuntimePlyBytesLoaderTest.cs | 15 ++++++-- 9 files changed, 125 insertions(+), 42 deletions(-) diff --git a/Editor/GsplatImporter.cs b/Editor/GsplatImporter.cs index 212fc67..3aa4e9e 100644 --- a/Editor/GsplatImporter.cs +++ b/Editor/GsplatImporter.cs @@ -10,11 +10,17 @@ namespace Gsplat.Editor { - [ScriptedImporter(1, new[] { "ply", "spz" })] + [ScriptedImporter(2, new[] { "ply", "spz" })] 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" + @@ -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); @@ -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) @@ -89,6 +99,14 @@ public override void OnImportAsset(AssetImportContext ctx) swTotal?.Stop(); } + if (gsplatAsset.SourceSplatCount > gsplatAsset.SplatCount) + { + var removed = gsplatAsset.SourceSplatCount - gsplatAsset.SplatCount; + UnityEngine.Debug.Log( + $"[Gsplat Import] {Path.GetFileName(ctx.assetPath)}: pruned {removed:N0} / {gsplatAsset.SourceSplatCount:N0} splats " + + $"({removed * 100.0 / gsplatAsset.SourceSplatCount:F1}%) below opacity {OpacityPruneThreshold}"); + } + ctx.AddObjectToAsset("gsplatAsset", gsplatAsset); ctx.SetMainObject(gsplatAsset); @@ -97,13 +115,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"); } diff --git a/Editor/GsplatImporterEditor.cs b/Editor/GsplatImporterEditor.cs index b6b970f..93b0c18 100644 --- a/Editor/GsplatImporterEditor.cs +++ b/Editor/GsplatImporterEditor.cs @@ -15,6 +15,7 @@ public override void OnInspectorGUI() EditorGUILayout.PropertyField(serializedObject.FindProperty("Compression")); EditorGUILayout.PropertyField(serializedObject.FindProperty("SourceCoordinates")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("OpacityPruneThreshold")); serializedObject.ApplyModifiedProperties(); ApplyRevertGUI(); diff --git a/README.md b/README.md index 69e4ccb..4970ec6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Runtime/GsplatAsset.cs b/Runtime/GsplatAsset.cs index fdd445e..2b3f17e 100644 --- a/Runtime/GsplatAsset.cs +++ b/Runtime/GsplatAsset.cs @@ -140,6 +140,8 @@ public PlyHeaderInfo(Stream fs) public abstract class GsplatAsset : ScriptableObject { public uint SplatCount; + // Splat count of the source file before import-time pruning; equals SplatCount when nothing was pruned. + [HideInInspector] public uint SourceSplatCount; public byte SHBands; // 0, 1, 2, 3, or 4 public Bounds Bounds; public abstract CompressionMode Compression { get; } @@ -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(); diff --git a/Runtime/GsplatAssetSpark.cs b/Runtime/GsplatAssetSpark.cs index 303da06..f71a9aa 100644 --- a/Runtime/GsplatAssetSpark.cs +++ b/Runtime/GsplatAssetSpark.cs @@ -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; + SourceSplatCount = plyInfo.VertexCount; SHBands = GsplatUtils.CalcSHBandsFromSHPropertyCount(plyInfo.SHPropertyCount); if (SHBands > 4 || GsplatUtils.SHBandsToCoefficientCount(SHBands) * 3 != plyInfo.SHPropertyCount) @@ -206,6 +208,7 @@ 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); @@ -213,6 +216,10 @@ void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoord throw new EndOfStreamException($"unexpected end of file, got {readBytes} bytes at vertex {i}"); var properties = MemoryMarshal.Cast(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++) { @@ -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; } @@ -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( @@ -258,8 +265,26 @@ 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; + 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); } } diff --git a/Runtime/GsplatAssetSpz.cs b/Runtime/GsplatAssetSpz.cs index 729e9f3..8353f0c 100644 --- a/Runtime/GsplatAssetSpz.cs +++ b/Runtime/GsplatAssetSpz.cs @@ -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, diff --git a/Runtime/GsplatAssetSpzUncompressed.cs b/Runtime/GsplatAssetSpzUncompressed.cs index 6c4303d..df34143 100644 --- a/Runtime/GsplatAssetSpzUncompressed.cs +++ b/Runtime/GsplatAssetSpzUncompressed.cs @@ -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, diff --git a/Runtime/GsplatAssetUncompressed.cs b/Runtime/GsplatAssetUncompressed.cs index 0a6ae74..1bbd0e2 100644 --- a/Runtime/GsplatAssetUncompressed.cs +++ b/Runtime/GsplatAssetUncompressed.cs @@ -124,30 +124,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; + SourceSplatCount = plyInfo.VertexCount; SHBands = GsplatUtils.CalcSHBandsFromSHPropertyCount(plyInfo.SHPropertyCount); if (SHBands > 4 || GsplatUtils.SHBandsToCoefficientCount(SHBands) * 3 != plyInfo.SHPropertyCount) @@ -164,6 +166,7 @@ void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoord Allocate(); var buffer = new byte[plyInfo.PropertyCount * sizeof(float)]; + 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); @@ -171,15 +174,21 @@ void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoord throw new EndOfStreamException($"unexpected end of file, got {readBytes} bytes at vertex {i}"); var properties = MemoryMarshal.Cast(buffer); - Positions[i] = new Vector3( + progressCallback?.Invoke("Reading vertices", i / (float)plyInfo.VertexCount); + + var alpha = GsplatUtils.Sigmoid(properties[plyInfo.OpacityOffset]); + if (alpha < opacityPruneThreshold) + continue; + + Positions[w] = new Vector3( posXSign * properties[plyInfo.PositionOffset], posYSign * properties[plyInfo.PositionOffset + 1], posZSign * properties[plyInfo.PositionOffset + 2]); - Colors[i] = new Vector4( + Colors[w] = new Vector4( properties[plyInfo.ColorOffset], properties[plyInfo.ColorOffset + 1], properties[plyInfo.ColorOffset + 2], - GsplatUtils.Sigmoid(properties[plyInfo.OpacityOffset])); + alpha); for (int j = 0, bandOffset = 0; j < SHBands; j++) { @@ -187,7 +196,7 @@ void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoord for (int k = 0; k < bandSize; k++) { float sign = GsplatUtils.ShSign(sourceCoordinates, j + 1, k); - int idx = (int)i * shCoeffs + bandOffset + k; + int idx = (int)w * shCoeffs + bandOffset + k; SHs[idx] = sign * new Vector3( properties[bandOffset + k + plyInfo.SHOffset], properties[bandOffset + k + plyInfo.SHOffset + shCoeffs], @@ -196,20 +205,34 @@ void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoord bandOffset += bandSize; } - Scales[i] = new Vector3( + Scales[w] = new Vector3( Mathf.Exp(properties[plyInfo.ScaleOffset]), Mathf.Exp(properties[plyInfo.ScaleOffset + 1]), Mathf.Exp(properties[plyInfo.ScaleOffset + 2])); - Rotations[i] = new Vector4( + Rotations[w] = new Vector4( properties[plyInfo.RotationOffset], rotXSign * properties[plyInfo.RotationOffset + 1], rotYSign * properties[plyInfo.RotationOffset + 2], rotZSign * properties[plyInfo.RotationOffset + 3]).normalized; - if (i == 0) Bounds = new Bounds(Positions[i], Vector3.zero); - else Bounds.Encapsulate(Positions[i]); + if (w == 0) Bounds = new Bounds(Positions[w], Vector3.zero); + else Bounds.Encapsulate(Positions[w]); + w++; + } + + if (w == 0 && plyInfo.VertexCount > 0) + throw new NotSupportedException( + $"opacity prune threshold {opacityPruneThreshold} removed all {plyInfo.VertexCount} splats"); - progressCallback?.Invoke("Reading vertices", i / (float)plyInfo.VertexCount); + if (w != SplatCount) + { + SplatCount = w; + Array.Resize(ref Positions, (int)w); + Array.Resize(ref Colors, (int)w); + Array.Resize(ref Scales, (int)w); + Array.Resize(ref Rotations, (int)w); + if (SHBands > 0) + Array.Resize(ref SHs, (int)w * shCoeffs); } } } diff --git a/Runtime/RuntimePlyBytesLoaderTest.cs b/Runtime/RuntimePlyBytesLoaderTest.cs index 835e774..c51014f 100644 --- a/Runtime/RuntimePlyBytesLoaderTest.cs +++ b/Runtime/RuntimePlyBytesLoaderTest.cs @@ -17,6 +17,10 @@ public class RuntimePlyBytesLoaderTest : MonoBehaviour public CompressionMode Compression = CompressionMode.Spark; public SourceCoordinates SourceCoordinates = SourceCoordinates.RUB; + [Tooltip("Removes splats whose opacity (after sigmoid) is below this value while loading. 0 disables pruning.")] + [Range(0f, 0.99f)] + public float OpacityPruneThreshold = 0f; + void Start() { var path = Path.IsPathRooted(PlyPath) @@ -34,11 +38,16 @@ void Start() GsplatAsset asset = Compression == CompressionMode.Spark ? ScriptableObject.CreateInstance() : ScriptableObject.CreateInstance(); - asset.LoadFromPlyBytes(bytes, null, SourceCoordinates); + asset.LoadFromPlyBytes(bytes, null, SourceCoordinates, OpacityPruneThreshold); GetComponent().GsplatAsset = asset; - Debug.Log($"Loaded {asset.SplatCount} splats (SH bands {asset.SHBands}) " + - $"from {bytes.Length} bytes via LoadFromPlyBytes"); + var pruned = asset.SourceSplatCount - asset.SplatCount; + Debug.Log($"Loaded {asset.SplatCount:N0} splats (SH bands {asset.SHBands}) " + + $"from {bytes.Length:N0} bytes via LoadFromPlyBytes" + + (pruned > 0 + ? $", pruned {pruned:N0} / {asset.SourceSplatCount:N0} splats " + + $"({pruned * 100.0 / asset.SourceSplatCount:F1}%) below opacity {OpacityPruneThreshold}" + : "")); } } } From c5176f8a7e71d6adc88921dd2d49c617134fc6e2 Mon Sep 17 00:00:00 2001 From: wuyize Date: Sat, 18 Jul 2026 23:19:42 +0800 Subject: [PATCH 2/3] Revert GsplatImporter version to 1; hide OpacityPruneThreshold for .spz files; update CHANGELOG.md. --- CHANGELOG.md | 2 ++ Editor/GsplatImporter.cs | 2 +- Editor/GsplatImporterEditor.cs | 10 ++++++++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d4700..bef5cc1 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 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)) diff --git a/Editor/GsplatImporter.cs b/Editor/GsplatImporter.cs index 3aa4e9e..ca80d1d 100644 --- a/Editor/GsplatImporter.cs +++ b/Editor/GsplatImporter.cs @@ -10,7 +10,7 @@ namespace Gsplat.Editor { - [ScriptedImporter(2, new[] { "ply", "spz" })] + [ScriptedImporter(1, new[] { "ply", "spz" })] public class GsplatImporter : ScriptedImporter { public CompressionMode Compression = CompressionMode.Spark; diff --git a/Editor/GsplatImporterEditor.cs b/Editor/GsplatImporterEditor.cs index 93b0c18..b90ce2d 100644 --- a/Editor/GsplatImporterEditor.cs +++ b/Editor/GsplatImporterEditor.cs @@ -1,6 +1,7 @@ // Copyright (c) 2025 Niantic Spatial // SPDX-License-Identifier: MIT +using System.IO; using UnityEditor; using UnityEditor.AssetImporters; @@ -15,10 +16,15 @@ public override void OnInspectorGUI() EditorGUILayout.PropertyField(serializedObject.FindProperty("Compression")); EditorGUILayout.PropertyField(serializedObject.FindProperty("SourceCoordinates")); - EditorGUILayout.PropertyField(serializedObject.FindProperty("OpacityPruneThreshold")); + + 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(); } } -} +} \ No newline at end of file From 901246b641c05e6deb8ed7c5f8625a312afdedbf Mon Sep 17 00:00:00 2001 From: wuyize Date: Sat, 18 Jul 2026 23:56:27 +0800 Subject: [PATCH 3/3] change SourceSplatCount to PrunedSplatCount in GsplatAsset --- Editor/GsplatImporter.cs | 9 +++++---- Runtime/GsplatAsset.cs | 4 ++-- Runtime/GsplatAssetSpark.cs | 3 ++- Runtime/GsplatAssetUncompressed.cs | 3 ++- Runtime/RuntimePlyBytesLoaderTest.cs | 9 +++++---- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/Editor/GsplatImporter.cs b/Editor/GsplatImporter.cs index ca80d1d..5defa8e 100644 --- a/Editor/GsplatImporter.cs +++ b/Editor/GsplatImporter.cs @@ -99,12 +99,13 @@ public override void OnImportAsset(AssetImportContext ctx) swTotal?.Stop(); } - if (gsplatAsset.SourceSplatCount > gsplatAsset.SplatCount) + if (gsplatAsset.PrunedSplatCount > 0) { - var removed = gsplatAsset.SourceSplatCount - gsplatAsset.SplatCount; + var removed = gsplatAsset.PrunedSplatCount; + var sourceSplatCount = gsplatAsset.SplatCount + gsplatAsset.PrunedSplatCount; UnityEngine.Debug.Log( - $"[Gsplat Import] {Path.GetFileName(ctx.assetPath)}: pruned {removed:N0} / {gsplatAsset.SourceSplatCount:N0} splats " + - $"({removed * 100.0 / gsplatAsset.SourceSplatCount:F1}%) below opacity {OpacityPruneThreshold}"); + $"[Gsplat Import] {Path.GetFileName(ctx.assetPath)}: pruned {removed:N0} / {sourceSplatCount:N0} splats " + + $"({removed * 100.0 / sourceSplatCount:F1}%) below opacity {OpacityPruneThreshold}"); } ctx.AddObjectToAsset("gsplatAsset", gsplatAsset); diff --git a/Runtime/GsplatAsset.cs b/Runtime/GsplatAsset.cs index 2b3f17e..357d60e 100644 --- a/Runtime/GsplatAsset.cs +++ b/Runtime/GsplatAsset.cs @@ -140,8 +140,8 @@ public PlyHeaderInfo(Stream fs) public abstract class GsplatAsset : ScriptableObject { public uint SplatCount; - // Splat count of the source file before import-time pruning; equals SplatCount when nothing was pruned. - [HideInInspector] public uint SourceSplatCount; + // 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; } diff --git a/Runtime/GsplatAssetSpark.cs b/Runtime/GsplatAssetSpark.cs index f71a9aa..0797c94 100644 --- a/Runtime/GsplatAssetSpark.cs +++ b/Runtime/GsplatAssetSpark.cs @@ -188,7 +188,7 @@ void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoord var plyInfo = new PlyHeaderInfo(fs); var shCoeffs = plyInfo.SHPropertyCount / 3; SplatCount = plyInfo.VertexCount; - SourceSplatCount = plyInfo.VertexCount; + var sourceSplatCount = plyInfo.VertexCount; SHBands = GsplatUtils.CalcSHBandsFromSHPropertyCount(plyInfo.SHPropertyCount); if (SHBands > 4 || GsplatUtils.SHBandsToCoefficientCount(SHBands) * 3 != plyInfo.SHPropertyCount) @@ -276,6 +276,7 @@ void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoord if (w != SplatCount) { SplatCount = w; + PrunedSplatCount = sourceSplatCount - SplatCount; Array.Resize(ref PackedSplats, (int)w); if (SHBands >= 1) Array.Resize(ref PackedSH1, (int)w * 2); diff --git a/Runtime/GsplatAssetUncompressed.cs b/Runtime/GsplatAssetUncompressed.cs index 1bbd0e2..0e59659 100644 --- a/Runtime/GsplatAssetUncompressed.cs +++ b/Runtime/GsplatAssetUncompressed.cs @@ -149,7 +149,7 @@ void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoord var plyInfo = new PlyHeaderInfo(fs); var shCoeffs = plyInfo.SHPropertyCount / 3; SplatCount = plyInfo.VertexCount; - SourceSplatCount = plyInfo.VertexCount; + var sourceSplatCount = plyInfo.VertexCount; SHBands = GsplatUtils.CalcSHBandsFromSHPropertyCount(plyInfo.SHPropertyCount); if (SHBands > 4 || GsplatUtils.SHBandsToCoefficientCount(SHBands) * 3 != plyInfo.SHPropertyCount) @@ -227,6 +227,7 @@ void LoadFromPlyStream(Stream fs, ProgressCallback progressCallback, SourceCoord if (w != SplatCount) { SplatCount = w; + PrunedSplatCount = sourceSplatCount - SplatCount; Array.Resize(ref Positions, (int)w); Array.Resize(ref Colors, (int)w); Array.Resize(ref Scales, (int)w); diff --git a/Runtime/RuntimePlyBytesLoaderTest.cs b/Runtime/RuntimePlyBytesLoaderTest.cs index c51014f..7cb48ce 100644 --- a/Runtime/RuntimePlyBytesLoaderTest.cs +++ b/Runtime/RuntimePlyBytesLoaderTest.cs @@ -41,13 +41,14 @@ void Start() asset.LoadFromPlyBytes(bytes, null, SourceCoordinates, OpacityPruneThreshold); GetComponent().GsplatAsset = asset; - var pruned = asset.SourceSplatCount - asset.SplatCount; + var pruned = asset.PrunedSplatCount; + var sourceSplatCount = asset.SplatCount + asset.PrunedSplatCount; Debug.Log($"Loaded {asset.SplatCount:N0} splats (SH bands {asset.SHBands}) " + $"from {bytes.Length:N0} bytes via LoadFromPlyBytes" + (pruned > 0 - ? $", pruned {pruned:N0} / {asset.SourceSplatCount:N0} splats " + - $"({pruned * 100.0 / asset.SourceSplatCount:F1}%) below opacity {OpacityPruneThreshold}" + ? $", pruned {pruned:N0} / {sourceSplatCount:N0} splats " + + $"({pruned * 100.0 / sourceSplatCount:F1}%) below opacity {OpacityPruneThreshold}" : "")); } } -} +} \ No newline at end of file