From edb678a3926ccb02bfa75491ef349cc48b4bb3af Mon Sep 17 00:00:00 2001 From: Bert Temme Date: Fri, 6 Mar 2026 10:20:29 +0100 Subject: [PATCH] initial coding KHR_gaussian_splatting --- .../KHR_GaussianSplatting.cs | 27 +++ .../KhronosExtensions.cs | 1 + src/SharpGLTF.Core/README.md | 16 ++ .../Generated/ext.GaussianSplatting.g.cs | 113 ++++++++++ .../Schema2/gltf.ExtensionsFactory.cs | 1 + .../gltf.MeshPrimitive.GaussianSplatting.cs | 201 ++++++++++++++++++ .../Schema2/gltf.MeshPrimitive.cs | 2 + .../GaussianSplattingExtensionTests.cs | 151 +++++++++++++ 8 files changed, 512 insertions(+) create mode 100644 build/SharpGLTF.codeGen.Extensions.Khronos/KHR_GaussianSplatting.cs create mode 100644 src/SharpGLTF.Core/Schema2/Generated/ext.GaussianSplatting.g.cs create mode 100644 src/SharpGLTF.Core/Schema2/gltf.MeshPrimitive.GaussianSplatting.cs create mode 100644 tests/SharpGLTF.Core.Tests/Schema2/Authoring/GaussianSplattingExtensionTests.cs diff --git a/build/SharpGLTF.codeGen.Extensions.Khronos/KHR_GaussianSplatting.cs b/build/SharpGLTF.codeGen.Extensions.Khronos/KHR_GaussianSplatting.cs new file mode 100644 index 00000000..b0a4d3e9 --- /dev/null +++ b/build/SharpGLTF.codeGen.Extensions.Khronos/KHR_GaussianSplatting.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Text; + +using SharpGLTF.SchemaReflection; + +namespace SharpGLTF +{ + class GaussianSplattingExtension : SchemaProcessor + { + private static string SchemaUri => KhronosExtensions.KhronosExtensionPath("KHR_gaussian_splatting", "mesh.primitive.KHR_gaussian_splatting.schema.json"); + + private const string ExtensionRootClassName = "KHR_gaussian_splatting glTF Mesh Primitive Extension"; + + public override IEnumerable<(string, SchemaType.Context)> ReadSchema() + { + var ctx = SchemaProcessing.LoadExtensionSchemaContext(SchemaUri); + + yield return ("ext.GaussianSplatting.g", ctx); + } + + public override void PrepareTypes(CodeGen.CSharpEmitter newEmitter, SchemaType.Context ctx) + { + newEmitter.SetRuntimeName(ExtensionRootClassName, "GaussianSplatting"); + } + } +} diff --git a/build/SharpGLTF.codeGen.Extensions.Khronos/KhronosExtensions.cs b/build/SharpGLTF.codeGen.Extensions.Khronos/KhronosExtensions.cs index c51cdcc2..5c9dddd5 100644 --- a/build/SharpGLTF.codeGen.Extensions.Khronos/KhronosExtensions.cs +++ b/build/SharpGLTF.codeGen.Extensions.Khronos/KhronosExtensions.cs @@ -30,6 +30,7 @@ public static IEnumerable GetExtensionsProcessors() // gpu mesh instancing yield return new MeshGpuInstancingExtension(); + yield return new GaussianSplattingExtension(); // textures yield return new TextureTransformExtension(); diff --git a/src/SharpGLTF.Core/README.md b/src/SharpGLTF.Core/README.md index 217543e8..66f3d5ba 100644 --- a/src/SharpGLTF.Core/README.md +++ b/src/SharpGLTF.Core/README.md @@ -50,6 +50,22 @@ It also handles the way a mesh is brought from its local space to world space, i - [x] [KHR_lights_punctual](https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Khronos/KHR_lights_punctual) - [x] [KHR_mesh_quantization](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_mesh_quantization) - [x] [EXT_mesh_gpu_instancing](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Vendor/EXT_mesh_gpu_instancing) +- [x] [KHR_gaussian_splatting](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_gaussian_splatting) + +#### KHR_gaussian_splatting quick usage + +```c# +var primitive = mesh.CreatePrimitive().WithIndicesAutomatic(PrimitiveType.POINTS); +primitive.WithVertexAccessor("POSITION", positions); +primitive.WithVertexAccessor(GaussianSplatting.AttributeRotation, rotations); +primitive.WithVertexAccessor(GaussianSplatting.AttributeScale, scales); +primitive.WithVertexAccessor(GaussianSplatting.AttributeOpacity, opacities); +primitive.WithVertexAccessor(GaussianSplatting.AttributeSHDegree0Coef0, shDegree0); + +var gaussian = primitive.UseGaussianSplatting(); +gaussian.Kernel = GaussianSplatting.KernelEllipse; +gaussian.ColorSpace = GaussianSplatting.ColorSpaceSrgbRec709Display; +``` - [x] [KHR_materials_pbrSpecularGlossiness](https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Khronos/KHR_materials_pbrSpecularGlossiness) - This extension has been declared _obsolete_ by Khronos, in favour of KHR_materials_specular + KHR_materials_IOR diff --git a/src/SharpGLTF.Core/Schema2/Generated/ext.GaussianSplatting.g.cs b/src/SharpGLTF.Core/Schema2/Generated/ext.GaussianSplatting.g.cs new file mode 100644 index 00000000..ba285096 --- /dev/null +++ b/src/SharpGLTF.Core/Schema2/Generated/ext.GaussianSplatting.g.cs @@ -0,0 +1,113 @@ +// + +//------------------------------------------------------------------------------------------------ +// This file has been programatically generated; DON“T EDIT! +//------------------------------------------------------------------------------------------------ + +#pragma warning disable SA1001 +#pragma warning disable SA1027 +#pragma warning disable SA1028 +#pragma warning disable SA1121 +#pragma warning disable SA1205 +#pragma warning disable SA1309 +#pragma warning disable SA1402 +#pragma warning disable SA1505 +#pragma warning disable SA1507 +#pragma warning disable SA1508 +#pragma warning disable SA1652 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Numerics; +using System.Text.Json; + +using JSONREADER = System.Text.Json.Utf8JsonReader; +using JSONWRITER = System.Text.Json.Utf8JsonWriter; +using FIELDINFO = SharpGLTF.Reflection.FieldInfo; + + +namespace SharpGLTF.Schema2 +{ + using Collections; + + /// + /// Data defining a 3D Gaussian Splat primitive. + /// + #if NET6_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicConstructors | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + #endif + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("SharpGLTF.CodeGen", "1.0.0.0")] + partial class GaussianSplatting : ExtraProperties + { + + #region reflection + + public new const string SCHEMANAME = "KHR_gaussian_splatting"; + protected override string GetSchemaName() => SCHEMANAME; + + protected override IEnumerable ReflectFieldsNames() + { + yield return "colorSpace"; + yield return "kernel"; + yield return "projection"; + yield return "sortingMethod"; + foreach(var f in base.ReflectFieldsNames()) yield return f; + } + protected override bool TryReflectField(string name, out FIELDINFO value) + { + switch(name) + { + case "colorSpace": value = FIELDINFO.From("colorSpace",this, instance => instance._colorSpace); return true; + case "kernel": value = FIELDINFO.From("kernel",this, instance => instance._kernel); return true; + case "projection": value = FIELDINFO.From("projection",this, instance => instance._projection ?? "perspective"); return true; + case "sortingMethod": value = FIELDINFO.From("sortingMethod",this, instance => instance._sortingMethod ?? "cameraDistance"); return true; + default: return base.TryReflectField(name, out value); + } + } + + #endregion + + #region data + + private String _colorSpace; + + private String _kernel; + + private const String _projectionDefault = "perspective"; + private String _projection; + + private const String _sortingMethodDefault = "cameraDistance"; + private String _sortingMethod; + + #endregion + + #region serialization + + protected override void SerializeProperties(JSONWRITER writer) + { + base.SerializeProperties(writer); + SerializeProperty(writer, "colorSpace", _colorSpace); + SerializeProperty(writer, "kernel", _kernel); + SerializeProperty(writer, "projection", _projection); + SerializeProperty(writer, "sortingMethod", _sortingMethod); + } + + protected override void DeserializeProperty(string jsonPropertyName, ref JSONREADER reader) + { + switch (jsonPropertyName) + { + case "colorSpace": DeserializePropertyValue(ref reader, this, out _colorSpace); break; + case "kernel": DeserializePropertyValue(ref reader, this, out _kernel); break; + case "projection": DeserializePropertyValue(ref reader, this, out _projection); break; + case "sortingMethod": DeserializePropertyValue(ref reader, this, out _sortingMethod); break; + default: base.DeserializeProperty(jsonPropertyName,ref reader); break; + } + } + + #endregion + + } + +} diff --git a/src/SharpGLTF.Core/Schema2/gltf.ExtensionsFactory.cs b/src/SharpGLTF.Core/Schema2/gltf.ExtensionsFactory.cs index efb0bac1..937e67ae 100644 --- a/src/SharpGLTF.Core/Schema2/gltf.ExtensionsFactory.cs +++ b/src/SharpGLTF.Core/Schema2/gltf.ExtensionsFactory.cs @@ -38,6 +38,7 @@ static ExtensionsFactory() RegisterExtension("KHR_lights_punctual", p=> new _NodePunctualLight(p)); RegisterExtension("EXT_mesh_gpu_instancing", p=> new MeshGpuInstancing(p)); RegisterExtension(_NodeVisibility.SCHEMANAME, p => new _NodeVisibility(p)); + RegisterExtension(GaussianSplatting.SCHEMANAME, p => new GaussianSplatting(p)); RegisterExtension("KHR_materials_unlit", p => new MaterialUnlit(p)); RegisterExtension("KHR_materials_sheen", p => new MaterialSheen(p)); diff --git a/src/SharpGLTF.Core/Schema2/gltf.MeshPrimitive.GaussianSplatting.cs b/src/SharpGLTF.Core/Schema2/gltf.MeshPrimitive.GaussianSplatting.cs new file mode 100644 index 00000000..d5956258 --- /dev/null +++ b/src/SharpGLTF.Core/Schema2/gltf.MeshPrimitive.GaussianSplatting.cs @@ -0,0 +1,201 @@ +using System; + +namespace SharpGLTF.Schema2 +{ + public partial class GaussianSplatting + { + #region lifecycle + + internal GaussianSplatting(MeshPrimitive primitive) + { + _Owner = primitive; + } + + #endregion + + #region constants + + public const string KernelEllipse = "ellipse"; + + public const string ColorSpaceSrgbRec709Display = "srgb_rec709_display"; + public const string ColorSpaceLinRec709Display = "lin_rec709_display"; + + public const string ProjectionPerspective = "perspective"; + public const string SortingMethodCameraDistance = "cameraDistance"; + + public const string AttributeRotation = "KHR_gaussian_splatting:ROTATION"; + public const string AttributeScale = "KHR_gaussian_splatting:SCALE"; + public const string AttributeOpacity = "KHR_gaussian_splatting:OPACITY"; + public const string AttributeSHDegree0Coef0 = "KHR_gaussian_splatting:SH_DEGREE_0_COEF_0"; + + #endregion + + #region data (not serializable) + + private readonly MeshPrimitive _Owner; + + #endregion + + #region properties + + public MeshPrimitive LogicalParent => _Owner; + + public string Kernel + { + get => _kernel; + set + { + Guard.NotNullOrEmpty(value, nameof(value)); + _kernel = value; + } + } + + public string ColorSpace + { + get => _colorSpace; + set + { + Guard.NotNullOrEmpty(value, nameof(value)); + _colorSpace = value; + } + } + + public string Projection + { + get => _projection ?? _projectionDefault; + set + { + value = value.AsEmptyNullable(); + _projection = value == _projectionDefault ? null : value; + } + } + + public string SortingMethod + { + get => _sortingMethod ?? _sortingMethodDefault; + set + { + value = value.AsEmptyNullable(); + _sortingMethod = value == _sortingMethodDefault ? null : value; + } + } + + #endregion + + #region API + + public static string GetSphericalHarmonicsAttribute(int degree, int coefficient) + { + Guard.MustBeBetweenOrEqualTo(degree, 0, 3, nameof(degree)); + + var coefficientCount = 2 * degree + 1; + Guard.MustBeBetweenOrEqualTo(coefficient, 0, coefficientCount - 1, nameof(coefficient)); + + return $"KHR_gaussian_splatting:SH_DEGREE_{degree}_COEF_{coefficient}"; + } + + #endregion + } + + public sealed partial class MeshPrimitive + { + private static readonly string[] _GaussianRequiredAttributes = + { + GaussianSplatting.AttributeRotation, + GaussianSplatting.AttributeScale, + GaussianSplatting.AttributeOpacity, + GaussianSplatting.AttributeSHDegree0Coef0 + }; + + public GaussianSplatting GetGaussianSplatting() + { + return this.GetExtension(); + } + + public GaussianSplatting UseGaussianSplatting() + { + var ext = GetGaussianSplatting(); + if (ext == null) + { + ext = new GaussianSplatting(this); + this.SetExtension(ext); + } + + return ext; + } + + public void RemoveGaussianSplatting() + { + this.RemoveExtensions(); + } + + private void _ValidateGaussianSplatting(Validation.ValidationContext validate) + { + var gaussian = GetGaussianSplatting(); + if (gaussian == null) return; + + validate.EnumsAreEqual(nameof(DrawPrimitiveType), DrawPrimitiveType, PrimitiveType.POINTS); + validate.IsDefined("Attributes.POSITION", GetVertexAccessor("POSITION")); + + foreach (var semantic in _GaussianRequiredAttributes) + { + validate.IsDefined($"Attributes.{semantic}", GetVertexAccessor(semantic)); + } + + var rotation = GetVertexAccessor(GaussianSplatting.AttributeRotation); + if (rotation != null) + { + validate.IsAnyOf + ( + $"Attributes.{GaussianSplatting.AttributeRotation}.Format", + rotation.Format, + (DimensionType.VEC4, EncodingType.FLOAT), + (DimensionType.VEC4, EncodingType.BYTE, true), + (DimensionType.VEC4, EncodingType.SHORT, true) + ); + } + + var scale = GetVertexAccessor(GaussianSplatting.AttributeScale); + if (scale != null) + { + validate.IsAnyOf + ( + $"Attributes.{GaussianSplatting.AttributeScale}.Format", + scale.Format, + (DimensionType.VEC3, EncodingType.FLOAT), + (DimensionType.VEC3, EncodingType.BYTE), + (DimensionType.VEC3, EncodingType.BYTE, true), + (DimensionType.VEC3, EncodingType.SHORT), + (DimensionType.VEC3, EncodingType.SHORT, true) + ); + } + + var opacity = GetVertexAccessor(GaussianSplatting.AttributeOpacity); + if (opacity != null) + { + validate.IsAnyOf + ( + $"Attributes.{GaussianSplatting.AttributeOpacity}.Format", + opacity.Format, + (DimensionType.SCALAR, EncodingType.FLOAT), + (DimensionType.SCALAR, EncodingType.UNSIGNED_BYTE, true), + (DimensionType.SCALAR, EncodingType.UNSIGNED_SHORT, true) + ); + } + + var sh0 = GetVertexAccessor(GaussianSplatting.AttributeSHDegree0Coef0); + if (sh0 != null) + { + validate.IsAnyOf + ( + $"Attributes.{GaussianSplatting.AttributeSHDegree0Coef0}.Format", + sh0.Format, + (DimensionType.VEC3, EncodingType.FLOAT) + ); + } + + validate.IsTrue(nameof(gaussian.Kernel), !string.IsNullOrWhiteSpace(gaussian.Kernel), "must be defined."); + validate.IsTrue(nameof(gaussian.ColorSpace), !string.IsNullOrWhiteSpace(gaussian.ColorSpace), "must be defined."); + } + } +} diff --git a/src/SharpGLTF.Core/Schema2/gltf.MeshPrimitive.cs b/src/SharpGLTF.Core/Schema2/gltf.MeshPrimitive.cs index 6adedf41..38634800 100644 --- a/src/SharpGLTF.Core/Schema2/gltf.MeshPrimitive.cs +++ b/src/SharpGLTF.Core/Schema2/gltf.MeshPrimitive.cs @@ -332,6 +332,8 @@ protected override void OnValidateContent(Validation.ValidationContext validate) { base.OnValidateContent(validate); + _ValidateGaussianSplatting(validate); + // all vertices must have the same vertex count int vertexCount = -1; diff --git a/tests/SharpGLTF.Core.Tests/Schema2/Authoring/GaussianSplattingExtensionTests.cs b/tests/SharpGLTF.Core.Tests/Schema2/Authoring/GaussianSplattingExtensionTests.cs new file mode 100644 index 00000000..fbe28151 --- /dev/null +++ b/tests/SharpGLTF.Core.Tests/Schema2/Authoring/GaussianSplattingExtensionTests.cs @@ -0,0 +1,151 @@ +using System.Linq; +using System.Numerics; + +using NUnit.Framework; + +namespace SharpGLTF.Schema2.Authoring +{ + [TestFixture] + [Category("Model Authoring")] + public class GaussianSplattingExtensionTests + { + private static readonly Vector3[] _Positions = + { + new Vector3(0, 0, 0), + new Vector3(1, 0, 0) + }; + + private static readonly Vector4[] _Rotations = + { + new Vector4(0, 0, 0, 1), + new Vector4(0, 0, 0, 1) + }; + + private static readonly Vector3[] _Scales = + { + Vector3.Zero, + Vector3.Zero + }; + + private static readonly float[] _Opacities = + { + 1, + 0.5f + }; + + private static readonly Vector3[] _Sh0 = + { + new Vector3(0.1f, 0.2f, 0.3f), + new Vector3(0.2f, 0.3f, 0.4f) + }; + + [Test] + public void CreateRoundtripWithGaussianSplattingExtension() + { + var model = ModelRoot.CreateModel(); + var primitive = _CreateMinimalGaussianPrimitive(model); + + var gaussian = primitive.UseGaussianSplatting(); + gaussian.Kernel = GaussianSplatting.KernelEllipse; + gaussian.ColorSpace = GaussianSplatting.ColorSpaceSrgbRec709Display; + gaussian.Projection = "custom_projection"; + gaussian.SortingMethod = "custom_sorting"; + + var roundtrip = ModelRoot.ParseGLB(model.WriteGLB()); + var roundtripPrimitive = roundtrip.LogicalMeshes[0].Primitives[0]; + var roundtripGaussian = roundtripPrimitive.GetGaussianSplatting(); + + Assert.That(roundtripGaussian, Is.Not.Null); + Assert.That(roundtripGaussian.Kernel, Is.EqualTo(gaussian.Kernel)); + Assert.That(roundtripGaussian.ColorSpace, Is.EqualTo(gaussian.ColorSpace)); + Assert.That(roundtripGaussian.Projection, Is.EqualTo(gaussian.Projection)); + Assert.That(roundtripGaussian.SortingMethod, Is.EqualTo(gaussian.SortingMethod)); + } + + [Test] + public void DetectGaussianSplattingExtensionUsage() + { + var model = ModelRoot.CreateModel(); + var primitive = _CreateMinimalGaussianPrimitive(model); + + var gaussian = primitive.UseGaussianSplatting(); + gaussian.Kernel = GaussianSplatting.KernelEllipse; + gaussian.ColorSpace = GaussianSplatting.ColorSpaceSrgbRec709Display; + + var used = model.GatherUsedExtensions().ToArray(); + Assert.That(used, Does.Contain(GaussianSplatting.SCHEMANAME)); + + var roundtrip = ModelRoot.ParseGLB(model.WriteGLB()); + Assert.That(roundtrip.ExtensionsUsed, Does.Contain(GaussianSplatting.SCHEMANAME)); + } + + [Test] + public void UseGetRemoveGaussianSplattingExtension() + { + var model = ModelRoot.CreateModel(); + var primitive = _CreateMinimalGaussianPrimitive(model); + + var extension = primitive.GetGaussianSplatting(); + Assert.That(extension, Is.Null); + + var created = primitive.UseGaussianSplatting(); + var reused = primitive.UseGaussianSplatting(); + + Assert.That(reused, Is.SameAs(created)); + Assert.That(created.Projection, Is.EqualTo(GaussianSplatting.ProjectionPerspective)); + Assert.That(created.SortingMethod, Is.EqualTo(GaussianSplatting.SortingMethodCameraDistance)); + Assert.That(GaussianSplatting.GetSphericalHarmonicsAttribute(2, 4), Is.EqualTo("KHR_gaussian_splatting:SH_DEGREE_2_COEF_4")); + + primitive.RemoveGaussianSplatting(); + Assert.That(primitive.GetGaussianSplatting(), Is.Null); + } + + [Test] + public void WriteGaussianSplattingWithInvalidModeThrows() + { + var model = ModelRoot.CreateModel(); + var primitive = _CreateMinimalGaussianPrimitive(model); + + var gaussian = primitive.UseGaussianSplatting(); + gaussian.Kernel = GaussianSplatting.KernelEllipse; + gaussian.ColorSpace = GaussianSplatting.ColorSpaceSrgbRec709Display; + + primitive.DrawPrimitiveType = PrimitiveType.TRIANGLES; + + Assert.That(() => model.WriteGLB(), Throws.TypeOf()); + } + + [Test] + public void WriteGaussianSplattingWithoutRequiredAttributeThrows() + { + var model = ModelRoot.CreateModel(); + var primitive = _CreateMinimalGaussianPrimitive(model); + + primitive.SetVertexAccessor(GaussianSplatting.AttributeOpacity, null); + + var gaussian = primitive.UseGaussianSplatting(); + gaussian.Kernel = GaussianSplatting.KernelEllipse; + gaussian.ColorSpace = GaussianSplatting.ColorSpaceSrgbRec709Display; + + Assert.That(() => model.WriteGLB(), Throws.TypeOf()); + } + + private static MeshPrimitive _CreateMinimalGaussianPrimitive(ModelRoot model) + { + var scene = model.UseScene("default"); + var node = scene.CreateNode("gaussian"); + var mesh = model.CreateMesh("gaussian-mesh"); + + node.Mesh = mesh; + + var primitive = mesh.CreatePrimitive().WithIndicesAutomatic(PrimitiveType.POINTS); + primitive.WithVertexAccessor("POSITION", _Positions); + primitive.WithVertexAccessor(GaussianSplatting.AttributeRotation, _Rotations); + primitive.WithVertexAccessor(GaussianSplatting.AttributeScale, _Scales); + primitive.WithVertexAccessor(GaussianSplatting.AttributeOpacity, _Opacities); + primitive.WithVertexAccessor(GaussianSplatting.AttributeSHDegree0Coef0, _Sh0); + + return primitive; + } + } +}