Components/GaussianSplatVolume.cs
using System.Runtime.InteropServices;
namespace Sandbox;
/// <summary>
/// Controls whether splats inside this volume are included or excluded from rendering.
/// </summary>
public enum SplatVolumeMode
{
/// <summary>
/// When any Add volume exists, ONLY splats inside Add volumes are rendered.
/// Multiple Add volumes are unioned together.
/// </summary>
Add,
/// <summary>
/// Splats inside this volume are hidden, even if inside an Add volume.
/// Use for cutting holes in splat clouds.
/// </summary>
Subtract,
/// <summary>
/// Splats inside this volume are tinted with a specified color.
/// The tint is applied before lighting so it interacts naturally with scene lights.
/// </summary>
Color
}
/// <summary>
/// Shape used for the volume boundary test.
/// </summary>
public enum SplatVolumeShape
{
Box,
Sphere
}
/// <summary>
/// A boolean volume that controls Gaussian splat visibility.
/// Add volumes restrict rendering to only splats within their bounds.
/// Subtract volumes carve holes, hiding splats even if inside an Add volume.
/// Supports smooth falloff for soft transitions.
/// </summary>
[Title( "Gaussian Splat Volume" )]
[Category( "Rendering" )]
[Icon( "crop_free" )]
public sealed class GaussianSplatVolume : Component, Component.ExecuteInEditor
{
private SceneGaussianSplatSystem _system;
/// <summary>
/// Whether this volume adds or subtracts splat visibility.
/// </summary>
[Property, Order( -10 )]
public SplatVolumeMode Mode { get; set; } = SplatVolumeMode.Subtract;
/// <summary>
/// Shape of the volume boundary.
/// </summary>
[Property, Order( -9 )]
public SplatVolumeShape Shape { get; set; } = SplatVolumeShape.Box;
/// <summary>
/// Size of the box volume in world units (inches). Only used when Shape is Box.
/// </summary>
[Property, Group( "Shape" ), ShowIf( nameof( Shape ), SplatVolumeShape.Box )]
public Vector3 Scale { get; set; } = new Vector3( 100, 100, 100 );
/// <summary>
/// Radius of the sphere volume in world units (inches). Only used when Shape is Sphere.
/// </summary>
[Property, Group( "Shape" ), ShowIf( nameof( Shape ), SplatVolumeShape.Sphere ), Range( 1f, 10000f )]
public float Radius { get; set; } = 50f;
/// <summary>
/// Strength of the volume effect on splat opacity.
/// At 1.0, splats are fully shown (Add) or fully hidden (Subtract).
/// Lower values partially affect opacity for subtle blending.
/// </summary>
[Property, Group( "Effect" ), Range( 0f, 1f )]
public float Intensity { get; set; } = 1.0f;
/// <summary>
/// Distance in world units over which the volume effect fades at the boundary.
/// 0 = hard edge, higher values = smoother transition. Useful for soft holes.
/// </summary>
[Property, Group( "Effect" ), Range( 0f, 500f )]
public float Falloff { get; set; } = 0f;
/// <summary>
/// Tint color applied to splats inside this volume. Only used when Mode is Color.
/// Applied before lighting so it interacts naturally with scene lights.
/// </summary>
[Property, Group( "Effect" ), ShowIf( nameof( Mode ), SplatVolumeMode.Color )]
public Color TintColor { get; set; } = Color.Red;
/// <summary>
/// When set, this volume only affects splat renderers that have at least one of these tags.
/// Leave empty to affect all splat renderers.
/// </summary>
[Property, Group( "Tags" ), Title( "Include Tags" )]
public TagSet IncludeTags { get; set; }
/// <summary>
/// Splat renderers with any of these tags will be excluded from this volume's effect.
/// Takes priority over IncludeTags.
/// </summary>
[Property, Group( "Tags" ), Title( "Exclude Tags" )]
public TagSet ExcludeTags { get; set; }
protected override void OnEnabled()
{
_system = SceneGaussianSplatSystem.GetOrCreate( Scene.SceneWorld );
_system.RegisterVolume( this );
}
protected override void OnDisabled()
{
_system?.UnregisterVolume( this );
_system = null;
}
/// <summary>
/// Returns true if this volume should affect a splat object with the given tags.
/// ExcludeTags take priority over IncludeTags.
/// </summary>
internal bool AppliesToObject( TagSet objectTags )
{
// Exclude takes priority — if any exclude tag matches, skip this volume
if ( ExcludeTags is not null && ExcludeTags.Count() > 0 && objectTags.HasAny( ExcludeTags ) )
return false;
// If include tags are set, require at least one match
if ( IncludeTags is not null && IncludeTags.Count() > 0 )
return objectTags.HasAny( IncludeTags );
// No tag filters — applies to everything
return true;
}
/// <summary>
/// Build the GPU-ready volume data struct for this volume's current state.
/// </summary>
internal VolumeGpuData BuildGpuData()
{
var data = new VolumeGpuData();
// Mode encoding: 0=Add, 1=Subtract, 2=Color
float modeValue = Mode switch
{
SplatVolumeMode.Add => 0f,
SplatVolumeMode.Subtract => 1f,
SplatVolumeMode.Color => 2f,
_ => 0f
};
data.Params = new Vector4(
Intensity,
Falloff,
modeValue,
Shape == SplatVolumeShape.Box ? 0f : 1f
);
data.TintColor = new Vector4( TintColor.r, TintColor.g, TintColor.b, TintColor.a );
if ( Shape == SplatVolumeShape.Box )
{
// WorldToLocal: strips position and rotation, leaving the point in the
// box's local frame where the origin is center and axes are aligned.
// No scale normalization — distances remain in world units.
var invRot = WorldRotation.Inverse;
var worldToLocal = Matrix.CreateTranslation( -WorldPosition )
* Matrix.CreateRotation( invRot );
// Transpose for shader column-vector convention: mul(M, v)
data.WorldToLocal = worldToLocal.Transpose();
data.ShapeData = new Vector4( Scale.x * 0.5f, Scale.y * 0.5f, Scale.z * 0.5f, 0f );
}
else
{
// Sphere: store center and radius directly
data.WorldToLocal = Matrix.Identity;
data.ShapeData = new Vector4( WorldPosition.x, WorldPosition.y, WorldPosition.z, Radius );
}
return data;
}
protected override void DrawGizmos()
{
if ( !Gizmo.IsSelected && !Gizmo.IsHovered )
return;
var alpha = Gizmo.IsSelected ? 0.3f : 0.1f;
Gizmo.Draw.Color = Mode switch
{
SplatVolumeMode.Add => Gizmo.Colors.Green.WithAlpha( alpha ),
SplatVolumeMode.Subtract => Gizmo.Colors.Red.WithAlpha( alpha ),
SplatVolumeMode.Color => TintColor.WithAlpha( alpha ),
_ => Gizmo.Colors.Green.WithAlpha( alpha )
};
// Draw in local space (Gizmo system handles the transform)
if ( Shape == SplatVolumeShape.Box )
{
var box = new BBox( -Scale * 0.5f, Scale * 0.5f );
Gizmo.Draw.LineBBox( box );
// Draw falloff inner boundary
if ( Falloff > 0f )
{
Gizmo.Draw.Color = Gizmo.Draw.Color.WithAlpha( alpha * 0.5f );
var innerHalf = new Vector3(
MathF.Max( 0, Scale.x * 0.5f - Falloff ),
MathF.Max( 0, Scale.y * 0.5f - Falloff ),
MathF.Max( 0, Scale.z * 0.5f - Falloff )
);
Gizmo.Draw.LineBBox( new BBox( -innerHalf, innerHalf ) );
}
}
else
{
Gizmo.Draw.LineSphere( Vector3.Zero, Radius );
if ( Falloff > 0f )
{
Gizmo.Draw.Color = Gizmo.Draw.Color.WithAlpha( alpha * 0.5f );
Gizmo.Draw.LineSphere( Vector3.Zero, MathF.Max( 0, Radius - Falloff ) );
}
}
}
/// <summary>
/// GPU-side volume descriptor. Must match the HLSL VolumeData struct exactly.
/// </summary>
[StructLayout( LayoutKind.Sequential )]
internal struct VolumeGpuData
{
public Matrix WorldToLocal; // 64 bytes
public Vector4 Params; // 16 bytes: x=intensity, y=falloff, z=mode(0=add,1=sub,2=color), w=shape(0=box,1=sphere)
public Vector4 ShapeData; // 16 bytes: box=xyz half-extents; sphere=xyz center, w=radius
public Vector4 TintColor; // 16 bytes: rgba tint for Color mode
/// <summary>
/// Tag group filter bitmasks packed as floats via BitConverter.
/// x = IncludeGroupBits (uint as float), y = ExcludeGroupBits (uint as float).
/// GPU reads with asuint(). Set by the system during volume upload.
/// </summary>
public Vector4 GroupFilter; // 16 bytes: x=includeGroupBits, y=excludeGroupBits, zw=unused
}
}