Code/SceneGaussianSplatObject.cs
using System.Runtime.InteropServices;
namespace Sandbox;
/// <summary>
/// Data container for a single Gaussian splat cloud.
/// This Does NOT render on its own, it registers with the per-SceneWorld <see cref="SceneGaussianSplatSystem"/> which merges all objects into one globally-sorted draw call.
/// </summary>
public class SceneGaussianSplatObject
{
/// <summary>
/// GPU-side splat position, read by both depth and billboard shaders.
/// </summary>
[StructLayout( LayoutKind.Sequential )]
public struct SplatPosition
{
public Vector3 Position;
/// <summary>
/// Per-splat normalization factor for float16 covariance precision.
/// Covariance values are divided by this before packing to keep them in a range
/// where float16 has maximum precision, then multiplied back on the GPU.
/// </summary>
public float CovarianceScale;
}
/// <summary>
/// GPU-side splat data, read by billboard shader.
/// Color is packed as RGBA8 uint, covariance as 3 pairs of float16.
/// </summary>
[StructLayout( LayoutKind.Sequential )]
public struct SplatData
{
public uint PackedColor;
public uint CovAB0; // f16(CovA.x) | f16(CovA.y) << 16
public uint CovAB1; // f16(CovA.z) | f16(CovB.x) << 16
public uint CovAB2; // f16(CovB.y) | f16(CovB.z) << 16
}
/// <summary>
/// Size of each splat billboard in world units (inches).
/// </summary>
public float SplatSize { get; set; } = 3.0f;
/// <summary>
/// Whether this splat cloud has covariance data for ellipsoidal rendering.
/// </summary>
public bool HasCovariance { get; private set; }
/// <summary>
/// Whether this object is actively rendered. When false, the object's splats remain
/// in the GPU buffer but the cull shader skips them (no layout re-pack needed).
/// Used for fast enable/disable cycling without triggering expensive data re-uploads.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// Whether splats receive scene lighting via covariance-derived normals.
/// </summary>
public bool ReceiveLighting { get; set; } = true;
/// <summary>
/// Whether splats receive shadow darkening without full lighting.
/// Only takes effect when ReceiveLighting is false.
/// </summary>
public bool ReceiveShadows { get; set; }
/// <summary>
/// Shadow tint color for shadows-only mode. Darker = stronger shadow effect.
/// </summary>
public Color ShadowTint { get; set; } = new Color( 0.1f, 0.1f, 0.15f, 1.0f );
/// <summary>
/// Per-object color tint multiplied with each splat's color.
/// White (default) means no tinting.
/// </summary>
public Color Tint { get; set; } = Color.White;
/// <summary>
/// Maximum LOD distance in world units. 0 = LOD disabled.
/// </summary>
public float LODMaxDistance { get; set; }
/// <summary>
/// 8-sample LUT of the LOD curve (keep fraction at evenly spaced normalized distances).
/// Index 0 = t=0 (at camera), index 7 = t=1 (at max distance).
/// </summary>
public float[] LODCurveSamples { get; set; } = new float[8] { 1, 1, 1, 1, 1, 1, 1, 1 };
/// <summary>
/// Whether chunked LOD is enabled for this object. When true, the LOD curve is
/// evaluated per spatial chunk rather than per object.
/// </summary>
public bool EnableChunkedLOD { get; set; }
/// <summary>
/// Size of each spatial chunk in world units (inches). Used to compute chunk assignments.
/// </summary>
public float ChunkSize { get; set; } = 500f;
/// <summary>
/// Chunks whose center Y exceeds this local height are exempt from LOD (always full density).
/// 0 = disabled.
/// </summary>
public float ChunkHeightExemption { get; set; }
/// <summary>
/// Chunks whose center distance from object origin exceeds this are exempt from LOD.
/// 0 = disabled.
/// </summary>
public float ChunkDistanceExemption { get; set; }
/// <summary>
/// Per-splat chunk index (into ChunkCenters). Computed at load time.
/// null when chunked LOD is disabled or no data loaded.
/// </summary>
public ushort[] ChunkIds { get; private set; }
/// <summary>
/// Chunk center positions in local space. Index matches ChunkIds values.
/// </summary>
public Vector3[] ChunkCenters { get; private set; }
/// <summary>
/// Per-chunk flag: true if the chunk is exempt from LOD (height/distance exemption).
/// </summary>
public bool[] ChunkExempt { get; private set; }
/// <summary>
/// Number of spatial chunks for this object. 0 if chunked LOD is disabled.
/// </summary>
public int ChunkCount { get; private set; }
/// <summary>
/// Number of splats in this object.
/// </summary>
public int SplatCount { get; private set; }
/// <summary>
/// CPU-side position data, the system uploads this to the merged GPU buffer.
/// </summary>
public SplatPosition[] PositionData { get; private set; }
/// <summary>
/// CPU-side packed splat data (color + covariance)
/// </summary>
public SplatData[] SplatDataArray { get; private set; }
/// <summary>
/// Set to true when splat data has been updated and needs re-uploading.
/// The system clears this after upload.
/// </summary>
public bool DataChanged { get; set; }
/// <summary>
/// World-space transform for this splat cloud. Updated each frame by the component.
/// </summary>
public Transform Transform { get; set; } = Transform.Zero;
/// <summary>
/// Tags for this object, used for rendering flags etc.
/// </summary>
public TagSet Tags { get; } = new();
private readonly SceneGaussianSplatSystem _system;
private bool _destroyed;
public SceneGaussianSplatSystem System => _system;
public SceneGaussianSplatObject( SceneGaussianSplatSystem system )
{
_system = system;
_system.Register( this );
}
/// <summary>
/// Set splat positions and data. The unified render system will pick these up.
/// If the count changes from a previous non-zero value, marks the system layout dirty
/// because subsequent objects' buffer offsets have shifted.
/// </summary>
public void SetSplatData( SplatPosition[] positions, SplatData[] data, int count, bool hasCovariance = false )
{
// Detect offset shift: if this object already had splats and the count changed,
// all subsequent objects in the unified buffer have wrong offsets.
if ( SplatCount > 0 && count != SplatCount )
_system?.MarkLayoutDirty();
PositionData = positions;
SplatDataArray = data;
SplatCount = count;
HasCovariance = hasCovariance;
DataChanged = true;
}
/// <summary>
/// Check if this object is still valid (not destroyed)
/// </summary>
public bool IsValid() => !_destroyed && _system is not null;
/// <summary>
/// Compute spatial chunk assignments for all splats based on the current ChunkSize.
/// Should be called after SetSplatData when chunked LOD is enabled.
/// </summary>
public void ComputeChunks()
{
if ( PositionData is null || SplatCount == 0 || ChunkSize <= 0f )
{
ChunkIds = null;
ChunkCenters = null;
ChunkExempt = null;
ChunkCount = 0;
return;
}
float invChunkSize = 1f / ChunkSize;
// Assign each splat to a grid cell, collecting unique cells.
// We use a dictionary to map grid coordinates → chunk index.
var cellToChunk = new Dictionary<(int, int, int), int>();
var chunkIds = new ushort[SplatCount];
var chunkAccum = new List<(Vector3 Sum, int Count)>();
for ( int i = 0; i < SplatCount; i++ )
{
var pos = PositionData[i].Position;
int cx = (int)MathF.Floor( pos.x * invChunkSize );
int cy = (int)MathF.Floor( pos.y * invChunkSize );
int cz = (int)MathF.Floor( pos.z * invChunkSize );
var cell = (cx, cy, cz);
if ( !cellToChunk.TryGetValue( cell, out int chunkIdx ) )
{
chunkIdx = chunkAccum.Count;
// Limit to ushort max — if we exceed 65535 chunks, clamp to last.
if ( chunkIdx >= ushort.MaxValue )
{
chunkIdx = ushort.MaxValue - 1;
}
else
{
cellToChunk[cell] = chunkIdx;
chunkAccum.Add( (Vector3.Zero, 0) );
}
}
chunkIds[i] = (ushort)chunkIdx;
// Accumulate for center computation
var (sum, count) = chunkAccum[chunkIdx];
chunkAccum[chunkIdx] = (sum + pos, count + 1);
}
// Compute chunk centers (average position of all splats in the chunk)
int chunkCount = chunkAccum.Count;
var centers = new Vector3[chunkCount];
var exempt = new bool[chunkCount];
for ( int i = 0; i < chunkCount; i++ )
{
var (sum, count) = chunkAccum[i];
centers[i] = sum / count;
// Evaluate exemptions
bool isExempt = false;
if ( ChunkHeightExemption > 0f && centers[i].z > ChunkHeightExemption )
isExempt = true;
if ( ChunkDistanceExemption > 0f && centers[i].Length > ChunkDistanceExemption )
isExempt = true;
exempt[i] = isExempt;
}
ChunkIds = chunkIds;
ChunkCenters = centers;
ChunkExempt = exempt;
ChunkCount = chunkCount;
DataChanged = true;
}
/// <summary>
/// Clear chunk data (when chunked LOD is disabled).
/// </summary>
public void ClearChunks()
{
ChunkIds = null;
ChunkCenters = null;
ChunkExempt = null;
ChunkCount = 0;
DataChanged = true;
}
/// <summary>
/// Clean up and unregister from the system.
/// </summary>
public void Destroy()
{
if ( _destroyed ) return;
_destroyed = true;
_system?.Unregister( this );
PositionData = null;
SplatDataArray = null;
SplatCount = 0;
}
}