Files
XCEngine/MVS/3DGS-Unity/Editor/GaussianSplatRendererEditor.cs
2026-03-29 01:36:53 +08:00

444 lines
19 KiB
C#

// SPDX-License-Identifier: MIT
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using GaussianSplatting.Runtime;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Mathematics;
using UnityEditor;
using UnityEditor.EditorTools;
using UnityEngine;
using GaussianSplatRenderer = GaussianSplatting.Runtime.GaussianSplatRenderer;
namespace GaussianSplatting.Editor
{
[CustomEditor(typeof(GaussianSplatRenderer))]
[CanEditMultipleObjects]
public class GaussianSplatRendererEditor : UnityEditor.Editor
{
const string kPrefExportBake = "nesnausk.GaussianSplatting.ExportBakeTransform";
SerializedProperty m_PropAsset;
SerializedProperty m_PropSplatScale;
SerializedProperty m_PropOpacityScale;
SerializedProperty m_PropSHOrder;
SerializedProperty m_PropSHOnly;
SerializedProperty m_PropSortNthFrame;
SerializedProperty m_PropRenderMode;
SerializedProperty m_PropPointDisplaySize;
SerializedProperty m_PropCutouts;
SerializedProperty m_PropShaderSplats;
SerializedProperty m_PropShaderComposite;
SerializedProperty m_PropShaderDebugPoints;
SerializedProperty m_PropShaderDebugBoxes;
SerializedProperty m_PropCSSplatUtilities;
bool m_ResourcesExpanded = false;
int m_CameraIndex = 0;
bool m_ExportBakeTransform;
static int s_EditStatsUpdateCounter = 0;
static HashSet<GaussianSplatRendererEditor> s_AllEditors = new();
public static void BumpGUICounter()
{
++s_EditStatsUpdateCounter;
}
public static void RepaintAll()
{
foreach (var e in s_AllEditors)
e.Repaint();
}
public void OnEnable()
{
m_ExportBakeTransform = EditorPrefs.GetBool(kPrefExportBake, false);
m_PropAsset = serializedObject.FindProperty("m_Asset");
m_PropSplatScale = serializedObject.FindProperty("m_SplatScale");
m_PropOpacityScale = serializedObject.FindProperty("m_OpacityScale");
m_PropSHOrder = serializedObject.FindProperty("m_SHOrder");
m_PropSHOnly = serializedObject.FindProperty("m_SHOnly");
m_PropSortNthFrame = serializedObject.FindProperty("m_SortNthFrame");
m_PropRenderMode = serializedObject.FindProperty("m_RenderMode");
m_PropPointDisplaySize = serializedObject.FindProperty("m_PointDisplaySize");
m_PropCutouts = serializedObject.FindProperty("m_Cutouts");
m_PropShaderSplats = serializedObject.FindProperty("m_ShaderSplats");
m_PropShaderComposite = serializedObject.FindProperty("m_ShaderComposite");
m_PropShaderDebugPoints = serializedObject.FindProperty("m_ShaderDebugPoints");
m_PropShaderDebugBoxes = serializedObject.FindProperty("m_ShaderDebugBoxes");
m_PropCSSplatUtilities = serializedObject.FindProperty("m_CSSplatUtilities");
s_AllEditors.Add(this);
}
public void OnDisable()
{
s_AllEditors.Remove(this);
}
public override void OnInspectorGUI()
{
var gs = target as GaussianSplatRenderer;
if (!gs)
return;
serializedObject.Update();
GUILayout.Label("Data Asset", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(m_PropAsset);
if (!gs.HasValidAsset)
{
var msg = gs.asset != null && gs.asset.formatVersion != GaussianSplatAsset.kCurrentVersion
? "Gaussian Splat asset version is not compatible, please recreate the asset"
: "Gaussian Splat asset is not assigned or is empty";
EditorGUILayout.HelpBox(msg, MessageType.Error);
}
EditorGUILayout.Space();
GUILayout.Label("Render Options", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(m_PropSplatScale);
EditorGUILayout.PropertyField(m_PropOpacityScale);
EditorGUILayout.PropertyField(m_PropSHOrder);
EditorGUILayout.PropertyField(m_PropSHOnly);
EditorGUILayout.PropertyField(m_PropSortNthFrame);
EditorGUILayout.Space();
GUILayout.Label("Debugging Tweaks", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(m_PropRenderMode);
if (m_PropRenderMode.intValue is (int)GaussianSplatRenderer.RenderMode.DebugPoints or (int)GaussianSplatRenderer.RenderMode.DebugPointIndices)
EditorGUILayout.PropertyField(m_PropPointDisplaySize);
EditorGUILayout.Space();
m_ResourcesExpanded = EditorGUILayout.Foldout(m_ResourcesExpanded, "Resources", true, EditorStyles.foldoutHeader);
if (m_ResourcesExpanded)
{
EditorGUILayout.PropertyField(m_PropShaderSplats);
EditorGUILayout.PropertyField(m_PropShaderComposite);
EditorGUILayout.PropertyField(m_PropShaderDebugPoints);
EditorGUILayout.PropertyField(m_PropShaderDebugBoxes);
EditorGUILayout.PropertyField(m_PropCSSplatUtilities);
}
bool validAndEnabled = gs && gs.enabled && gs.gameObject.activeInHierarchy && gs.HasValidAsset;
if (validAndEnabled && !gs.HasValidRenderSetup)
{
EditorGUILayout.HelpBox("Shader resources are not set up", MessageType.Error);
validAndEnabled = false;
}
if (validAndEnabled && targets.Length == 1)
{
EditCameras(gs);
EditGUI(gs);
}
if (validAndEnabled && targets.Length > 1)
{
MultiEditGUI();
}
serializedObject.ApplyModifiedProperties();
}
void EditCameras(GaussianSplatRenderer gs)
{
var asset = gs.asset;
var cameras = asset.cameras;
if (cameras != null && cameras.Length != 0)
{
EditorGUILayout.Space();
GUILayout.Label("Cameras", EditorStyles.boldLabel);
var camIndex = EditorGUILayout.IntSlider("Camera", m_CameraIndex, 0, cameras.Length - 1);
camIndex = math.clamp(camIndex, 0, cameras.Length - 1);
if (camIndex != m_CameraIndex)
{
m_CameraIndex = camIndex;
gs.ActivateCamera(camIndex);
}
}
}
void MultiEditGUI()
{
DrawSeparator();
CountTargetSplats(out var totalSplats, out var totalObjects);
EditorGUILayout.LabelField("Total Objects", $"{totalObjects}");
EditorGUILayout.LabelField("Total Splats", $"{totalSplats:N0}");
if (totalSplats > GaussianSplatAsset.kMaxSplats)
{
EditorGUILayout.HelpBox($"Can't merge, too many splats (max. supported {GaussianSplatAsset.kMaxSplats:N0})", MessageType.Warning);
return;
}
var targetGs = (GaussianSplatRenderer) target;
if (!targetGs || !targetGs.HasValidAsset || !targetGs.isActiveAndEnabled)
{
EditorGUILayout.HelpBox($"Can't merge into {target.name} (no asset or disable)", MessageType.Warning);
return;
}
if (targetGs.asset.chunkData != null)
{
EditorGUILayout.HelpBox($"Can't merge into {target.name} (needs to use Very High quality preset)", MessageType.Warning);
return;
}
if (GUILayout.Button($"Merge into {target.name}"))
{
MergeSplatObjects();
}
}
void CountTargetSplats(out int totalSplats, out int totalObjects)
{
totalObjects = 0;
totalSplats = 0;
foreach (var obj in targets)
{
var gs = obj as GaussianSplatRenderer;
if (!gs || !gs.HasValidAsset || !gs.isActiveAndEnabled)
continue;
++totalObjects;
totalSplats += gs.splatCount;
}
}
void MergeSplatObjects()
{
CountTargetSplats(out var totalSplats, out _);
if (totalSplats > GaussianSplatAsset.kMaxSplats)
return;
var targetGs = (GaussianSplatRenderer) target;
int copyDstOffset = targetGs.splatCount;
targetGs.EditSetSplatCount(totalSplats);
foreach (var obj in targets)
{
var gs = obj as GaussianSplatRenderer;
if (!gs || !gs.HasValidAsset || !gs.isActiveAndEnabled)
continue;
if (gs == targetGs)
continue;
gs.EditCopySplatsInto(targetGs, 0, copyDstOffset, gs.splatCount);
copyDstOffset += gs.splatCount;
gs.gameObject.SetActive(false);
}
Debug.Assert(copyDstOffset == totalSplats, $"Merge count mismatch, {copyDstOffset} vs {totalSplats}");
Selection.activeObject = targetGs;
}
void EditGUI(GaussianSplatRenderer gs)
{
++s_EditStatsUpdateCounter;
DrawSeparator();
bool wasToolActive = ToolManager.activeContextType == typeof(GaussianToolContext);
GUILayout.BeginHorizontal();
bool isToolActive = GUILayout.Toggle(wasToolActive, "Edit", EditorStyles.miniButton);
using (new EditorGUI.DisabledScope(!gs.editModified))
{
if (GUILayout.Button("Reset", GUILayout.ExpandWidth(false)))
{
if (EditorUtility.DisplayDialog("Reset Splat Modifications?",
$"This will reset edits of {gs.name} to match the {gs.asset.name} asset. Continue?",
"Yes, reset", "Cancel"))
{
gs.enabled = false;
gs.enabled = true;
}
}
}
GUILayout.EndHorizontal();
if (!wasToolActive && isToolActive)
{
ToolManager.SetActiveContext<GaussianToolContext>();
if (Tools.current == Tool.View)
Tools.current = Tool.Move;
}
if (wasToolActive && !isToolActive)
{
ToolManager.SetActiveContext<GameObjectToolContext>();
}
if (isToolActive && gs.asset.chunkData != null)
{
EditorGUILayout.HelpBox("Splat move/rotate/scale tools need Very High splat quality preset", MessageType.Warning);
}
EditorGUILayout.Space();
GUILayout.BeginHorizontal();
if (GUILayout.Button("Add Cutout"))
{
GaussianCutout cutout = ObjectFactory.CreateGameObject("GSCutout", typeof(GaussianCutout)).GetComponent<GaussianCutout>();
Transform cutoutTr = cutout.transform;
cutoutTr.SetParent(gs.transform, false);
cutoutTr.localScale = (gs.asset.boundsMax - gs.asset.boundsMin) * 0.25f;
gs.m_Cutouts ??= Array.Empty<GaussianCutout>();
ArrayUtility.Add(ref gs.m_Cutouts, cutout);
gs.UpdateEditCountsAndBounds();
EditorUtility.SetDirty(gs);
Selection.activeGameObject = cutout.gameObject;
}
if (GUILayout.Button("Use All Cutouts"))
{
gs.m_Cutouts = FindObjectsByType<GaussianCutout>(FindObjectsSortMode.InstanceID);
gs.UpdateEditCountsAndBounds();
EditorUtility.SetDirty(gs);
}
if (GUILayout.Button("No Cutouts"))
{
gs.m_Cutouts = Array.Empty<GaussianCutout>();
gs.UpdateEditCountsAndBounds();
EditorUtility.SetDirty(gs);
}
GUILayout.EndHorizontal();
EditorGUILayout.PropertyField(m_PropCutouts);
bool hasCutouts = gs.m_Cutouts != null && gs.m_Cutouts.Length != 0;
bool modifiedOrHasCutouts = gs.editModified || hasCutouts;
var asset = gs.asset;
EditorGUILayout.Space();
EditorGUI.BeginChangeCheck();
m_ExportBakeTransform = EditorGUILayout.Toggle("Export in world space", m_ExportBakeTransform);
if (EditorGUI.EndChangeCheck())
{
EditorPrefs.SetBool(kPrefExportBake, m_ExportBakeTransform);
}
if (GUILayout.Button("Export PLY"))
ExportPlyFile(gs, m_ExportBakeTransform);
if (asset.posFormat > GaussianSplatAsset.VectorFormat.Norm16 ||
asset.scaleFormat > GaussianSplatAsset.VectorFormat.Norm16 ||
asset.colorFormat > GaussianSplatAsset.ColorFormat.Float16x4 ||
asset.shFormat > GaussianSplatAsset.SHFormat.Float16)
{
EditorGUILayout.HelpBox(
"It is recommended to use High or VeryHigh quality preset for editing splats, lower levels are lossy",
MessageType.Warning);
}
bool displayEditStats = isToolActive || modifiedOrHasCutouts;
EditorGUILayout.Space();
EditorGUILayout.LabelField("Splats", $"{gs.splatCount:N0}");
if (displayEditStats)
{
EditorGUILayout.LabelField("Cut", $"{gs.editCutSplats:N0}");
EditorGUILayout.LabelField("Deleted", $"{gs.editDeletedSplats:N0}");
EditorGUILayout.LabelField("Selected", $"{gs.editSelectedSplats:N0}");
if (hasCutouts)
{
if (s_EditStatsUpdateCounter > 10)
{
gs.UpdateEditCountsAndBounds();
s_EditStatsUpdateCounter = 0;
}
}
}
}
static void DrawSeparator()
{
EditorGUILayout.Space(12f, true);
GUILayout.Box(GUIContent.none, "sv_iconselector_sep", GUILayout.Height(2), GUILayout.ExpandWidth(true));
EditorGUILayout.Space();
}
bool HasFrameBounds()
{
return true;
}
Bounds OnGetFrameBounds()
{
var gs = target as GaussianSplatRenderer;
if (!gs || !gs.HasValidRenderSetup)
return new Bounds(Vector3.zero, Vector3.one);
Bounds bounds = default;
bounds.SetMinMax(gs.asset.boundsMin, gs.asset.boundsMax);
if (gs.editSelectedSplats > 0)
{
bounds = gs.editSelectedBounds;
}
bounds.extents *= 0.7f;
return TransformBounds(gs.transform, bounds);
}
public static Bounds TransformBounds(Transform tr, Bounds bounds )
{
var center = tr.TransformPoint(bounds.center);
var ext = bounds.extents;
var axisX = tr.TransformVector(ext.x, 0, 0);
var axisY = tr.TransformVector(0, ext.y, 0);
var axisZ = tr.TransformVector(0, 0, ext.z);
// sum their absolute value to get the world extents
ext.x = Mathf.Abs(axisX.x) + Mathf.Abs(axisY.x) + Mathf.Abs(axisZ.x);
ext.y = Mathf.Abs(axisX.y) + Mathf.Abs(axisY.y) + Mathf.Abs(axisZ.y);
ext.z = Mathf.Abs(axisX.z) + Mathf.Abs(axisY.z) + Mathf.Abs(axisZ.z);
return new Bounds { center = center, extents = ext };
}
static unsafe void ExportPlyFile(GaussianSplatRenderer gs, bool bakeTransform)
{
var path = EditorUtility.SaveFilePanel(
"Export Gaussian Splat PLY file", "", $"{gs.asset.name}-edit.ply", "ply");
if (string.IsNullOrWhiteSpace(path))
return;
int kSplatSize = UnsafeUtility.SizeOf<GaussianSplatAssetCreator.InputSplatData>();
using var gpuData = new GraphicsBuffer(GraphicsBuffer.Target.Structured, gs.splatCount, kSplatSize);
if (!gs.EditExportData(gpuData, bakeTransform))
return;
GaussianSplatAssetCreator.InputSplatData[] data = new GaussianSplatAssetCreator.InputSplatData[gpuData.count];
gpuData.GetData(data);
var gpuDeleted = gs.GpuEditDeleted;
uint[] deleted = new uint[gpuDeleted.count];
gpuDeleted.GetData(deleted);
// count non-deleted splats
int aliveCount = 0;
for (int i = 0; i < data.Length; ++i)
{
int wordIdx = i >> 5;
int bitIdx = i & 31;
bool isDeleted = (deleted[wordIdx] & (1u << bitIdx)) != 0;
bool isCutout = data[i].nor.sqrMagnitude > 0;
if (!isDeleted && !isCutout)
++aliveCount;
}
using FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write);
// note: this is a long string! but we don't use multiline literal because we want guaranteed LF line ending
var header = $"ply\nformat binary_little_endian 1.0\nelement vertex {aliveCount}\nproperty float x\nproperty float y\nproperty float z\nproperty float nx\nproperty float ny\nproperty float nz\nproperty float f_dc_0\nproperty float f_dc_1\nproperty float f_dc_2\nproperty float f_rest_0\nproperty float f_rest_1\nproperty float f_rest_2\nproperty float f_rest_3\nproperty float f_rest_4\nproperty float f_rest_5\nproperty float f_rest_6\nproperty float f_rest_7\nproperty float f_rest_8\nproperty float f_rest_9\nproperty float f_rest_10\nproperty float f_rest_11\nproperty float f_rest_12\nproperty float f_rest_13\nproperty float f_rest_14\nproperty float f_rest_15\nproperty float f_rest_16\nproperty float f_rest_17\nproperty float f_rest_18\nproperty float f_rest_19\nproperty float f_rest_20\nproperty float f_rest_21\nproperty float f_rest_22\nproperty float f_rest_23\nproperty float f_rest_24\nproperty float f_rest_25\nproperty float f_rest_26\nproperty float f_rest_27\nproperty float f_rest_28\nproperty float f_rest_29\nproperty float f_rest_30\nproperty float f_rest_31\nproperty float f_rest_32\nproperty float f_rest_33\nproperty float f_rest_34\nproperty float f_rest_35\nproperty float f_rest_36\nproperty float f_rest_37\nproperty float f_rest_38\nproperty float f_rest_39\nproperty float f_rest_40\nproperty float f_rest_41\nproperty float f_rest_42\nproperty float f_rest_43\nproperty float f_rest_44\nproperty float opacity\nproperty float scale_0\nproperty float scale_1\nproperty float scale_2\nproperty float rot_0\nproperty float rot_1\nproperty float rot_2\nproperty float rot_3\nend_header\n";
fs.Write(Encoding.UTF8.GetBytes(header));
for (int i = 0; i < data.Length; ++i)
{
int wordIdx = i >> 5;
int bitIdx = i & 31;
bool isDeleted = (deleted[wordIdx] & (1u << bitIdx)) != 0;
bool isCutout = data[i].nor.sqrMagnitude > 0;
if (!isDeleted && !isCutout)
{
var splat = data[i];
byte* ptr = (byte*)&splat;
fs.Write(new ReadOnlySpan<byte>(ptr, kSplatSize));
}
}
Debug.Log($"Exported PLY {path} with {aliveCount:N0} splats");
}
}
}