diff --git a/engine/Sandbox.Engine/Resources/Clutter/ClutterDefinition.cs b/engine/Sandbox.Engine/Resources/Clutter/ClutterDefinition.cs
new file mode 100644
index 00000000..1e6d4da1
--- /dev/null
+++ b/engine/Sandbox.Engine/Resources/Clutter/ClutterDefinition.cs
@@ -0,0 +1,80 @@
+using System.Text.Json.Serialization;
+
+namespace Sandbox.Clutter;
+
+///
+/// A weighted collection of Prefabs and Models for random selection during clutter placement.
+///
+[AssetType( Name = "Clutter Definition", Extension = "clutter", Category = "World" )]
+public class ClutterDefinition : GameResource
+{
+ ///
+ /// Tile size options for streaming mode.
+ ///
+ public enum TileSizeOption
+ {
+ [Title( "256" )] Size256 = 256,
+ [Title( "512" )] Size512 = 512,
+ [Title( "1024" )] Size1024 = 1024,
+ [Title( "2048" )] Size2048 = 2048,
+ [Title( "4096" )] Size4096 = 4096
+ }
+
+ ///
+ /// List of weighted entries
+ ///
+ [Property]
+ [Editor( "ClutterEntriesGrid" )]
+ public List Entries { get; set; } = [];
+
+ public bool IsEmpty => Entries.Count == 0;
+
+ ///
+ /// Size of each tile in world units for infinite streaming mode.
+ ///
+ [Property]
+ [Title( "Tile Size" )]
+ public TileSizeOption TileSizeEnum { get; set; } = TileSizeOption.Size512;
+
+ ///
+ /// Gets the tile size as a float value.
+ ///
+ [Hide, JsonIgnore]
+ public float TileSize => (float)TileSizeEnum;
+
+ ///
+ /// Number of tiles to generate around the camera in each direction.
+ /// Higher values = more visible range but more memory usage.
+ ///
+ [Property, Range( 1, 10 )]
+ public int TileRadius { get; set; } = 4;
+
+ [Property]
+ public AnyOfType Scatterer { get; set; } = new SimpleScatterer();
+
+ public override int GetHashCode()
+ {
+ var hash = new HashCode();
+ hash.Add( TileSize );
+ hash.Add( TileRadius );
+ hash.Add( Entries.Count );
+
+ foreach ( var entry in Entries )
+ {
+ if ( entry != null )
+ {
+ hash.Add( entry.Weight );
+ hash.Add( entry.Model?.GetHashCode() ?? 0 );
+ hash.Add( entry.Prefab?.GetHashCode() ?? 0 );
+ }
+ }
+
+ hash.Add( Scatterer.Value?.GetHashCode() ?? 0 );
+ return hash.ToHashCode();
+ }
+
+ protected override Bitmap CreateAssetTypeIcon( int width, int height )
+ {
+ return CreateSimpleAssetTypeIcon( "forest", width, height );
+ }
+}
diff --git a/engine/Sandbox.Engine/Resources/Clutter/ClutterEntry.cs b/engine/Sandbox.Engine/Resources/Clutter/ClutterEntry.cs
new file mode 100644
index 00000000..0a67e7f1
--- /dev/null
+++ b/engine/Sandbox.Engine/Resources/Clutter/ClutterEntry.cs
@@ -0,0 +1,36 @@
+namespace Sandbox.Clutter;
+
+///
+/// Represents a single weighted entry in a .
+/// Contains either a Prefab or Model reference along with spawn parameters.
+///
+public class ClutterEntry
+{
+ ///
+ /// Prefab to spawn. If set, this takes priority over .
+ ///
+ [Property]
+ public GameObject Prefab { get; set; }
+
+ ///
+ /// Model to spawn as a static prop. Only used if is null.
+ ///
+ [Property]
+ public Model Model { get; set; }
+
+ ///
+ /// Relative weight for random selection. Higher values = more likely to be chosen.
+ ///
+ [Property, Range( 0.01f, 1f )]
+ public float Weight { get; set; } = 1.0f;
+
+ ///
+ /// Returns whether this entry has a valid asset to spawn.
+ ///
+ public bool HasAsset => Prefab is not null || Model is not null;
+
+ ///
+ /// Returns the primary asset reference as a string for debugging.
+ ///
+ public string AssetName => Prefab?.Name ?? Model?.Name ?? "None";
+}
diff --git a/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterBatchSceneObject.cs b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterBatchSceneObject.cs
new file mode 100644
index 00000000..63954491
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterBatchSceneObject.cs
@@ -0,0 +1,79 @@
+using System.Runtime.InteropServices;
+
+namespace Sandbox.Clutter;
+
+///
+/// Custom scene object for rendering batched clutter models.
+/// Groups instances by model type for efficient GPU instanced rendering.
+///
+internal class ClutterBatchSceneObject : SceneCustomObject
+{
+ ///
+ /// Batches by model
+ ///
+ private readonly Dictionary _batches = [];
+
+ public ClutterBatchSceneObject( SceneWorld world ) : base( world )
+ {
+ Flags.IsOpaque = true;
+ Flags.IsTranslucent = false;
+ Flags.CastShadows = true;
+ Flags.WantsPrePass = true;
+ }
+
+ ///
+ /// Adds a clutter instance to the appropriate batch.
+ ///
+ public void AddInstance( ClutterInstance instance )
+ {
+ if ( instance.Entry?.Model == null )
+ return;
+
+ var model = instance.Entry.Model;
+
+ if ( !_batches.TryGetValue( model, out var batch ) )
+ {
+ batch = new ClutterModelBatch( model );
+ _batches[model] = batch;
+ }
+
+ batch.AddInstance( instance.Transform );
+ }
+
+ ///
+ /// Clears all batches.
+ ///
+ public void Clear()
+ {
+ foreach ( var batch in _batches.Values )
+ batch.Clear();
+
+ _batches.Clear();
+ }
+
+ ///
+ /// Called when the batch is deleted. Cleans up resources.
+ ///
+ public new void Delete()
+ {
+ Clear();
+ base.Delete();
+ }
+
+ ///
+ /// Renders all batched instances using GPU instancing.
+ ///
+ public override void RenderSceneObject()
+ {
+ if ( _batches.Count == 0 )
+ return;
+
+ foreach ( var (model, batch) in _batches )
+ {
+ if ( batch.Transforms.Count == 0 || model == null )
+ continue;
+
+ Graphics.DrawModelInstanced( model, CollectionsMarshal.AsSpan( batch.Transforms ) );
+ }
+ }
+}
diff --git a/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterComponent.Infinite.cs b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterComponent.Infinite.cs
new file mode 100644
index 00000000..87884eb0
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterComponent.Infinite.cs
@@ -0,0 +1,55 @@
+namespace Sandbox.Clutter;
+
+///
+/// Infinite/streaming clutter mode
+///
+public sealed partial class ClutterComponent
+{
+ ///
+ /// Returns true if in infinite streaming mode.
+ ///
+ [Hide]
+ public bool Infinite => Mode == ClutterMode.Infinite;
+
+ ///
+ /// Gets the current clutter settings for the grid system.
+ ///
+ internal ClutterSettings GetCurrentSettings()
+ {
+ if ( Clutter == null )
+ return default;
+
+ return new ClutterSettings( Seed, Clutter );
+ }
+
+ ///
+ /// Clears all infinite mode tiles for this component.
+ ///
+ public void ClearInfinite()
+ {
+ var gridSystem = Scene.GetSystem();
+ gridSystem?.ClearComponent( this );
+ }
+
+ ///
+ /// Invalidates the tile at the given world position, causing it to regenerate.
+ ///
+ public void InvalidateTileAt( Vector3 worldPosition )
+ {
+ if ( Mode != ClutterMode.Infinite ) return;
+
+ var gridSystem = Scene.GetSystem();
+ gridSystem.InvalidateTileAt( this, worldPosition );
+ }
+
+ ///
+ /// Invalidates all tiles within the given bounds, causing them to regenerate.
+ ///
+ public void InvalidateTilesInBounds( BBox bounds )
+ {
+ if ( Mode != ClutterMode.Infinite ) return;
+
+ var gridSystem = Scene.GetSystem();
+ gridSystem.InvalidateTilesInBounds( this, bounds );
+ }
+}
diff --git a/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterComponent.Volume.cs b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterComponent.Volume.cs
new file mode 100644
index 00000000..3473a9e3
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterComponent.Volume.cs
@@ -0,0 +1,259 @@
+namespace Sandbox.Clutter;
+
+///
+/// Volume/baked clutter mode - generates once within bounds
+///
+public sealed partial class ClutterComponent
+{
+ [Property, Group( "Volume" ), ShowIf( nameof( Infinite ), false )]
+ public BBox Bounds { get; set; } = new BBox( new Vector3( -512, -512, -512 ), new Vector3( 512, 512, 512 ) );
+
+ ///
+ /// Storage for volume model instances. Serialized with component.
+ ///
+ [Property, Hide]
+ public ClutterGridSystem.ClutterStorage Storage { get; set; } = new();
+
+ ///
+ /// Layer used for rendering volume model instances.
+ ///
+ private ClutterLayer _volumeLayer;
+
+ ///
+ /// Tracks pending tile count for progressive volume generation.
+ ///
+ private int _pendingVolumeTiles;
+
+ ///
+ /// Total tiles queued for current generation.
+ ///
+ private int _totalVolumeTiles;
+
+ private Editor.IProgressSection _progressSection;
+
+ [Button( "Generate" )]
+ [Icon( "scatter_plot" )]
+ public void Generate()
+ {
+ if ( Infinite ) return;
+
+ Clear();
+
+ if ( Clutter == null )
+ return;
+
+ var gridSystem = Scene.GetSystem();
+ if ( gridSystem == null ) return;
+
+ var settings = GetCurrentSettings();
+ _volumeLayer ??= gridSystem.GetOrCreateLayer( this, settings );
+
+ Storage ??= new();
+ Storage.ClearAll();
+
+ var worldBounds = Bounds.Transform( WorldTransform );
+ var tileSize = Clutter.TileSize;
+
+ // Calculate tile grid covering the volume
+ var minTile = new Vector2Int(
+ (int)Math.Floor( worldBounds.Mins.x / tileSize ),
+ (int)Math.Floor( worldBounds.Mins.y / tileSize )
+ );
+ var maxTile = new Vector2Int(
+ (int)Math.Floor( worldBounds.Maxs.x / tileSize ),
+ (int)Math.Floor( worldBounds.Maxs.y / tileSize )
+ );
+
+
+ _pendingVolumeTiles = 0;
+ _totalVolumeTiles = 0;
+
+ // Queue generation jobs for each tile
+ for ( int x = minTile.x; x <= maxTile.x; x++ )
+ for ( int y = minTile.y; y <= maxTile.y; y++ )
+ {
+ var tileBounds = new BBox(
+ new Vector3( x * tileSize, y * tileSize, worldBounds.Mins.z ),
+ new Vector3( (x + 1) * tileSize, (y + 1) * tileSize, worldBounds.Maxs.z )
+ );
+
+ if ( !tileBounds.Overlaps( worldBounds ) )
+ continue;
+
+ var scatterBounds = new BBox(
+ Vector3.Max( tileBounds.Mins, worldBounds.Mins ),
+ Vector3.Min( tileBounds.Maxs, worldBounds.Maxs )
+ );
+
+ var job = new ClutterGenerationJob
+ {
+ Clutter = Clutter,
+ Parent = GameObject,
+ Bounds = scatterBounds,
+ Seed = Scatterer.GenerateSeed( Seed, x, y ),
+ Ownership = ClutterOwnership.Component,
+ Layer = _volumeLayer,
+ Storage = Storage,
+ LocalBounds = Bounds,
+ VolumeTransform = WorldTransform,
+ OnComplete = () => _pendingVolumeTiles--
+ };
+
+ gridSystem.QueueJob( job );
+ _pendingVolumeTiles++;
+ _totalVolumeTiles++;
+ }
+
+
+ if ( _totalVolumeTiles > 8 && Scene.IsEditor )
+ {
+ _progressSection = Application.Editor.ProgressSection();
+ }
+ }
+
+ internal void UpdateVolumeProgress()
+ {
+ // Only track progress if we have pending tiles and we're in editor
+ if ( _totalVolumeTiles == 0 || !Scene.IsEditor )
+ {
+ if ( _progressSection != null )
+ {
+ _progressSection.Dispose();
+ _progressSection = null;
+ }
+ return;
+ }
+
+ // Show progress for larger generations
+ if ( _totalVolumeTiles > 8 )
+ {
+ var processed = _totalVolumeTiles - _pendingVolumeTiles;
+
+ if ( _progressSection == null )
+ {
+ _progressSection = Application.Editor.ProgressSection();
+ }
+
+ if ( _progressSection.GetCancel().IsCancellationRequested )
+ {
+ CancelGeneration();
+ return;
+ }
+
+ _progressSection.Title = "Generating Clutter";
+ _progressSection.Subtitle = $"Processing tile {processed}/{_totalVolumeTiles}";
+ _progressSection.TotalCount = _totalVolumeTiles;
+ _progressSection.Current = processed;
+ if ( _pendingVolumeTiles == 0 )
+ {
+ _totalVolumeTiles = 0;
+ _progressSection?.Dispose();
+ _progressSection = null;
+ }
+ }
+ }
+
+ ///
+ /// Cancels ongoing volume generation.
+ ///
+ private void CancelGeneration()
+ {
+ var gridSystem = Scene.GetSystem();
+ if ( gridSystem != null )
+ {
+ gridSystem.ClearComponent( this );
+ }
+
+ // Reset tracking
+ _pendingVolumeTiles = 0;
+ _totalVolumeTiles = 0;
+ if ( _progressSection != null )
+ {
+ _progressSection.Dispose();
+ _progressSection = null;
+ }
+
+ }
+
+ ///
+ /// Rebuilds the visual layer from stored model instances.
+ /// Called on scene load and when entering play mode.
+ ///
+ internal void RebuildVolumeLayer()
+ {
+ if ( Storage == null || Storage.TotalCount == 0 )
+ {
+ _volumeLayer?.ClearAllTiles();
+ return;
+ }
+
+ var gridSystem = Scene.GetSystem();
+ if ( gridSystem == null ) return;
+
+ var settings = GetCurrentSettings();
+ _volumeLayer ??= gridSystem.GetOrCreateLayer( this, settings );
+ _volumeLayer.ClearAllTiles();
+
+ // Rebuild model instances from storage
+ foreach ( var modelPath in Storage.ModelPaths )
+ {
+ var model = ResourceLibrary.Get( modelPath );
+ if ( model == null ) continue;
+
+ foreach ( var instance in Storage.GetInstances( modelPath ) )
+ {
+ _volumeLayer.AddModelInstance( Vector2Int.Zero, new ClutterInstance
+ {
+ Transform = new Transform( instance.Position, instance.Rotation, instance.Scale ),
+ Entry = new ClutterEntry { Model = model }
+ } );
+ }
+ }
+
+ _volumeLayer.RebuildBatches();
+ }
+
+ [Button( "Clear" )]
+ [Icon( "delete" )]
+ public void Clear()
+ {
+ ClearInfinite();
+ ClearVolume();
+ }
+
+ private void ClearVolume()
+ {
+ Storage?.ClearAll();
+ _volumeLayer?.ClearAllTiles();
+
+ // Ensure any in-progress volume generation UI/state is cleaned up
+ _progressSection?.Dispose();
+ _progressSection = null;
+ _pendingVolumeTiles = 0;
+ _totalVolumeTiles = 0;
+
+ // Destroy prefab children
+ var children = GameObject.Children.Where( c => c.Tags.Has( "clutter" ) ).ToArray();
+ foreach ( var child in children )
+ child.Destroy();
+ }
+
+ private void DrawVolumeGizmos()
+ {
+ if ( !Gizmo.IsSelected )
+ return;
+
+ using ( Gizmo.Scope( "volume" ) )
+ {
+ Gizmo.Draw.Color = Color.Green.WithAlpha( 0.3f );
+ Gizmo.Draw.LineBBox( Bounds );
+
+ Gizmo.Draw.Color = Color.Green.WithAlpha( 0.05f );
+
+ if ( Gizmo.Control.BoundingBox( "bounds", Bounds, out var newBounds ) )
+ {
+ Bounds = newBounds;
+ }
+ }
+ }
+}
diff --git a/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterComponent.cs b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterComponent.cs
new file mode 100644
index 00000000..083e116d
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterComponent.cs
@@ -0,0 +1,77 @@
+namespace Sandbox.Clutter;
+
+///
+/// Clutter scattering component supporting both infinite and volumes.
+///
+[Icon( "forest" )]
+[EditorHandle( Icon = "forest" )]
+public sealed partial class ClutterComponent : Component, Component.ExecuteInEditor
+{
+ ///
+ /// Clutter generation mode.
+ ///
+ public enum ClutterMode
+ {
+ [Icon( "inventory_2" ), Description( "Scatter clutter within a defined volume" )]
+ Volume,
+
+ [Icon( "all_inclusive" ), Description( "Stream clutter infinitely around the camera" )]
+ Infinite
+ }
+
+ ///
+ /// The clutter containing objects to scatter and scatter settings.
+ ///
+ [Property]
+ public ClutterDefinition Clutter { get; set; }
+
+ ///
+ /// Seed for deterministic generation. Change to get different variations.
+ ///
+ [Property]
+ public int Seed { get; set; }
+
+ ///
+ /// Clutter generation mode - Volume or Infinite streaming.
+ ///
+ [Property]
+ public ClutterMode Mode
+ {
+ get => field;
+ set
+ {
+ if ( field == value ) return;
+ Clear();
+ field = value;
+ }
+ }
+
+ protected override void OnEnabled()
+ {
+ if ( Mode == ClutterMode.Volume )
+ {
+ RebuildVolumeLayer();
+ }
+ }
+
+ protected override void OnDisabled()
+ {
+ Clear();
+ }
+
+ protected override void OnUpdate()
+ {
+ if ( Mode == ClutterMode.Volume )
+ {
+ UpdateVolumeProgress();
+ }
+ }
+
+ protected override void DrawGizmos()
+ {
+ if ( Mode == ClutterMode.Volume )
+ {
+ DrawVolumeGizmos();
+ }
+ }
+}
diff --git a/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterGenerationJob.cs b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterGenerationJob.cs
new file mode 100644
index 00000000..af47a44f
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterGenerationJob.cs
@@ -0,0 +1,163 @@
+using static Sandbox.Clutter.ClutterGridSystem;
+
+namespace Sandbox.Clutter;
+
+///
+/// Defines who owns the generated clutter instances.
+///
+enum ClutterOwnership
+{
+ ///
+ /// Component owns instances. Models stored in component's Storage, prefabs saved with scene.
+ /// Used for volume mode.
+ ///
+ Component,
+
+ ///
+ /// GridSystem owns instances. Prefabs are unsaved/hidden, tiles manage cleanup.
+ /// Used for infinite streaming mode.
+ ///
+ GridSystem
+}
+
+///
+/// Unified job for clutter generation.
+///
+class ClutterGenerationJob
+{
+ ///
+ /// The clutter definition containing entries and scatterer.
+ ///
+ public required ClutterDefinition Clutter { get; init; }
+
+ ///
+ /// Parent GameObject for spawned prefabs.
+ ///
+ public required GameObject Parent { get; init; }
+
+ ///
+ /// Bounds to scatter within.
+ ///
+ public required BBox Bounds { get; init; }
+
+ ///
+ /// Random seed for deterministic generation.
+ ///
+ public required int Seed { get; init; }
+
+ ///
+ /// Who owns the generated instances.
+ ///
+ public required ClutterOwnership Ownership { get; init; }
+
+ ///
+ /// Layer for batched model rendering.
+ ///
+ public ClutterLayer Layer { get; init; }
+
+ ///
+ /// Tile data for infinite mode (null for volume mode).
+ ///
+ public ClutterTile Tile { get; init; }
+
+ ///
+ /// Storage for component-owned model instances
+ ///
+ public ClutterStorage Storage { get; init; }
+
+ ///
+ /// Optional callback when job completes (for volume mode progress tracking).
+ ///
+ public Action OnComplete { get; init; }
+
+ public BBox? LocalBounds { get; init; }
+ public Transform? VolumeTransform { get; init; }
+
+ ///
+ /// Execute the generation job.
+ ///
+ public void Execute()
+ {
+ try
+ {
+ if ( !Parent.IsValid() )
+ return;
+
+ int seed = Seed;
+ if ( Tile != null )
+ {
+ Tile.Destroy();
+ Layer?.ClearTileModelInstances( Tile.Coordinates );
+
+ seed = Scatterer.GenerateSeed( Tile.SeedOffset, Tile.Coordinates.x, Tile.Coordinates.y );
+ }
+
+ var instances = Clutter.Scatterer.HasValue
+ ? Clutter.Scatterer.Value.Scatter( Bounds, Clutter, seed, Parent.Scene )
+ : null;
+
+ if ( LocalBounds.HasValue && VolumeTransform.HasValue )
+ {
+ var volumeTransform = VolumeTransform.Value;
+ var localBounds = LocalBounds.Value;
+ instances?.RemoveAll( i => !localBounds.Contains( volumeTransform.PointToLocal( i.Transform.Position ) ) );
+ }
+
+ if ( instances is { Count: > 0 } )
+ SpawnInstances( instances );
+
+ if ( Tile != null )
+ {
+ Tile.IsPopulated = true;
+ Layer?.OnTilePopulated( Tile );
+ }
+ }
+ finally
+ {
+ OnComplete?.Invoke();
+ }
+ }
+
+ private void SpawnInstances( List instances )
+ {
+ var isComponentOwned = Ownership == ClutterOwnership.Component;
+ var tileCoord = Tile?.Coordinates ?? Vector2Int.Zero;
+
+ using ( Parent.Scene.Push() )
+ {
+ foreach ( var instance in instances )
+ {
+ if ( instance.IsModel )
+ {
+ Layer?.AddModelInstance( tileCoord, instance );
+
+ // Component ownership: also store in component's storage for persistence
+ if ( isComponentOwned )
+ {
+ Storage.AddInstance(
+ instance.Entry.Model.ResourcePath,
+ instance.Transform.Position,
+ instance.Transform.Rotation,
+ instance.Transform.Scale.x
+ );
+ }
+ continue;
+ }
+
+ if ( instance.Entry.Prefab == null )
+ continue;
+
+ var obj = instance.Entry.Prefab.Clone( instance.Transform, Parent.Scene );
+ obj.Tags.Add( "clutter" );
+ obj.SetParent( Parent );
+
+ if ( !isComponentOwned )
+ {
+ obj.Flags |= GameObjectFlags.NotSaved;
+ obj.Flags |= GameObjectFlags.Hidden;
+ Tile?.AddObject( obj );
+ }
+ }
+ }
+ }
+}
diff --git a/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterLayer.cs b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterLayer.cs
new file mode 100644
index 00000000..1f62c4b4
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterLayer.cs
@@ -0,0 +1,274 @@
+namespace Sandbox.Clutter;
+
+class ClutterLayer
+{
+ private Dictionary Tiles { get; } = [];
+
+ public ClutterSettings Settings { get; set; }
+
+ ///
+ /// Game object clutter will be placed under this parent
+ ///
+ public GameObject ParentObject { get; set; }
+
+ public ClutterGridSystem GridSystem { get; set; }
+
+ ///
+ /// Model instances organized by tile coordinate.
+ ///
+ private Dictionary> ModelInstancesByTile { get; } = [];
+
+ ///
+ /// Batches organized by model, containing all instances across all tiles in this layer.
+ ///
+ private readonly Dictionary _batches = [];
+
+ private int _lastSettingsHash;
+ private const float TileHeight = 50000f;
+
+ ///
+ /// batches need to be rebuilt
+ ///
+ private bool _dirty = false;
+
+ public ClutterLayer( ClutterSettings settings, GameObject parentObject, ClutterGridSystem gridSystem )
+ {
+ Settings = settings;
+ ParentObject = parentObject;
+ GridSystem = gridSystem;
+ _lastSettingsHash = settings.GetHashCode();
+ }
+
+ public void UpdateSettings( ClutterSettings newSettings )
+ {
+ var newHash = newSettings.GetHashCode();
+ if ( newHash == _lastSettingsHash )
+ return;
+
+ // Mark all tiles as needing regeneration (keeps old content visible)
+ foreach ( var tile in Tiles.Values )
+ {
+ tile.IsPopulated = false;
+ }
+
+ Settings = newSettings;
+ _lastSettingsHash = newHash;
+ }
+
+ public List UpdateTiles( Vector3 center )
+ {
+ if ( !Settings.IsValid )
+ return [];
+
+ var centerTile = WorldToTile( center );
+ var activeCoords = new HashSet();
+ var jobs = new List();
+
+ for ( int x = -Settings.Clutter.TileRadius; x <= Settings.Clutter.TileRadius; x++ )
+ for ( int y = -Settings.Clutter.TileRadius; y <= Settings.Clutter.TileRadius; y++ )
+ {
+ var coord = new Vector2Int( centerTile.x + x, centerTile.y + y );
+ activeCoords.Add( coord );
+
+ // Get or create tile
+ if ( !Tiles.TryGetValue( coord, out var tile ) )
+ {
+ tile = new ClutterTile
+ {
+ Coordinates = coord,
+ Bounds = GetTileBounds( coord ),
+ SeedOffset = Settings.RandomSeed
+ };
+ Tiles[coord] = tile;
+ }
+
+ // Queue job if not populated
+ if ( !tile.IsPopulated )
+ {
+ jobs.Add( new ClutterGenerationJob
+ {
+ Clutter = Settings.Clutter,
+ Parent = ParentObject,
+ Bounds = tile.Bounds,
+ Seed = Settings.RandomSeed,
+ Ownership = ClutterOwnership.GridSystem,
+ Layer = this,
+ Tile = tile
+ } );
+ }
+ }
+
+ // Remove out-of-range tiles
+ var toRemove = Tiles.Keys.Where( coord => !activeCoords.Contains( coord ) ).ToList();
+ if ( toRemove.Count > 0 )
+ {
+ foreach ( var coord in toRemove )
+ {
+ if ( Tiles.Remove( coord, out var tile ) )
+ {
+ // Remove from pending set first to prevent queue buildup
+ GridSystem?.RemovePendingTile( tile );
+ tile.Destroy();
+
+ // Remove model instances for this tile
+ ModelInstancesByTile.Remove( coord );
+ }
+ }
+ _dirty = true;
+ }
+
+ // Rebuild batches if needed
+ if ( _dirty && jobs.Count == 0 )
+ {
+ RebuildBatches();
+ }
+
+ return jobs;
+ }
+
+ ///
+ /// Called when a tile has been populated with instances.
+ /// Marks batches as dirty so they'll be rebuilt.
+ ///
+ public void OnTilePopulated( ClutterTile tile )
+ {
+ _dirty = true;
+ }
+
+ ///
+ /// Clears model instances for a specific tile coordinate.
+ ///
+ public void ClearTileModelInstances( Vector2Int tileCoord )
+ {
+ ModelInstancesByTile.Remove( tileCoord );
+ }
+
+ ///
+ /// Adds a model instance for a specific tile.
+ ///
+ public void AddModelInstance( Vector2Int tileCoord, ClutterInstance instance )
+ {
+ if ( instance.Entry?.Model == null )
+ return;
+
+ if ( !ModelInstancesByTile.TryGetValue( tileCoord, out var instances ) )
+ {
+ instances = [];
+ ModelInstancesByTile[tileCoord] = instances;
+ }
+
+ instances.Add( instance );
+ }
+
+ ///
+ /// Rebuilds all batches from scratch using all populated tiles.
+ ///
+ public void RebuildBatches()
+ {
+ var sceneWorld = ParentObject?.Scene?.SceneWorld ?? GridSystem?.Scene?.SceneWorld;
+ if ( sceneWorld == null )
+ {
+ _dirty = false;
+ return;
+ }
+
+ // Group instances by model
+ var instancesByModel = ModelInstancesByTile.Values
+ .SelectMany( instances => instances )
+ .Where( i => i.Entry?.Model != null )
+ .GroupBy( i => i.Entry.Model )
+ .ToDictionary( g => g.Key, g => g.ToList() );
+
+ foreach ( var batch in _batches.Values )
+ batch.Clear();
+
+ foreach ( var (model, instances) in instancesByModel )
+ {
+ if ( !_batches.TryGetValue( model, out var batch ) )
+ {
+ batch = new ClutterBatchSceneObject( sceneWorld );
+ _batches[model] = batch;
+ }
+
+ foreach ( var instance in instances )
+ batch.AddInstance( instance );
+ }
+
+ var toRemove = _batches.Keys.Where( m => !instancesByModel.ContainsKey( m ) ).ToList();
+ foreach ( var model in toRemove )
+ {
+ _batches[model].Delete();
+ _batches.Remove( model );
+ }
+
+ _dirty = false;
+ }
+
+ ///
+ /// Invalidates the tile at the given world position, causing it to regenerate.
+ ///
+ public void InvalidateTile( Vector3 worldPosition )
+ {
+ var coord = WorldToTile( worldPosition );
+ if ( Tiles.TryGetValue( coord, out var tile ) )
+ {
+ GridSystem?.RemovePendingTile( tile );
+ tile.Destroy();
+ ModelInstancesByTile.Remove( coord );
+ _dirty = true;
+ }
+ }
+
+ ///
+ /// Invalidates all tiles that intersect the given bounds, causing them to regenerate.
+ ///
+ public void InvalidateTilesInBounds( BBox bounds )
+ {
+ var minTile = WorldToTile( bounds.Mins );
+ var maxTile = WorldToTile( bounds.Maxs );
+
+ for ( int x = minTile.x; x <= maxTile.x; x++ )
+ for ( int y = minTile.y; y <= maxTile.y; y++ )
+ {
+ var coord = new Vector2Int( x, y );
+ if ( Tiles.TryGetValue( coord, out var tile ) )
+ {
+ GridSystem?.RemovePendingTile( tile );
+ tile.Destroy();
+ ModelInstancesByTile.Remove( coord );
+ _dirty = true;
+ }
+ }
+ }
+
+ public void ClearAllTiles()
+ {
+ // Remove any pending tiles from the grid system
+ foreach ( var tile in Tiles.Values )
+ {
+ GridSystem?.RemovePendingTile( tile );
+ tile.Destroy();
+ }
+
+ Tiles.Clear();
+ ModelInstancesByTile.Clear();
+
+ // Clear all batches
+ foreach ( var batch in _batches.Values )
+ {
+ batch.Delete();
+ }
+ _batches.Clear();
+ _dirty = false;
+ }
+
+ private Vector2Int WorldToTile( Vector3 worldPos ) => new(
+ (int)MathF.Floor( worldPos.x / Settings.Clutter.TileSize ),
+ (int)MathF.Floor( worldPos.y / Settings.Clutter.TileSize )
+ );
+
+ private BBox GetTileBounds( Vector2Int coord ) => new(
+ new Vector3( coord.x * Settings.Clutter.TileSize, coord.y * Settings.Clutter.TileSize, -TileHeight ),
+ new Vector3( (coord.x + 1) * Settings.Clutter.TileSize, (coord.y + 1) * Settings.Clutter.TileSize, TileHeight )
+ );
+}
diff --git a/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterModelBatch.cs b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterModelBatch.cs
new file mode 100644
index 00000000..2b4195a8
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterModelBatch.cs
@@ -0,0 +1,39 @@
+namespace Sandbox.Clutter;
+
+///
+/// Groups multiple instances of the same model for efficient batch rendering.
+///
+struct ClutterModelBatch
+{
+ ///
+ /// The model being rendered in this batch.
+ ///
+ public Model Model { get; set; }
+
+ ///
+ /// List of transforms for each instance.
+ ///
+ public List Transforms { get; set; } = [];
+
+ public ClutterModelBatch( Model model )
+ {
+ Model = model;
+ Transforms = [];
+ }
+
+ ///
+ /// Adds an instance to this batch.
+ ///
+ public void AddInstance( Transform transform )
+ {
+ Transforms.Add( transform );
+ }
+
+ ///
+ /// Clears all instances from this batch.
+ ///
+ public void Clear()
+ {
+ Transforms.Clear();
+ }
+}
diff --git a/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterSettings.cs b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterSettings.cs
new file mode 100644
index 00000000..baa1a605
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterSettings.cs
@@ -0,0 +1,32 @@
+namespace Sandbox.Clutter;
+
+///
+/// Immutable settings for clutter generation.
+/// Used to detect changes and configure the grid system.
+///
+readonly record struct ClutterSettings
+{
+ public int RandomSeed { get; init; }
+ public ClutterDefinition Clutter { get; init; }
+
+ public ClutterSettings( int randomSeed, ClutterDefinition definition )
+ {
+ RandomSeed = randomSeed;
+ Clutter = definition;
+ }
+
+ ///
+ /// Validates that settings are ready for clutter generation.
+ ///
+ public bool IsValid => Clutter != null;
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(
+ Clutter.TileSize,
+ Clutter.TileRadius,
+ RandomSeed,
+ Clutter?.GetHashCode() ?? 0
+ );
+ }
+}
diff --git a/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterTile.cs b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterTile.cs
new file mode 100644
index 00000000..354e2fce
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/Components/Clutter/ClutterTile.cs
@@ -0,0 +1,48 @@
+namespace Sandbox.Clutter;
+
+///
+/// Represents a single tile in the clutter spatial grid.
+/// Tracks spawned objects for cleanup when the tile is no longer needed.
+///
+class ClutterTile
+{
+ ///
+ /// Grid coordinates of this tile.
+ ///
+ public Vector2Int Coordinates { get; set; }
+
+ ///
+ /// World-space bounds of this tile.
+ ///
+ public BBox Bounds { get; set; }
+
+ ///
+ /// Random seed offset for deterministic generation.
+ ///
+ public int SeedOffset { get; set; }
+
+ ///
+ /// Whether this tile has been populated with clutter instances.
+ ///
+ public bool IsPopulated { get; internal set; }
+
+ ///
+ /// GameObjects spawned from prefab entries.
+ ///
+ internal List SpawnedObjects { get; } = [];
+
+ internal void AddObject( GameObject obj )
+ {
+ if ( obj.IsValid() )
+ SpawnedObjects.Add( obj );
+ }
+
+ internal void Destroy()
+ {
+ foreach ( var obj in SpawnedObjects )
+ if ( obj.IsValid() ) obj.Destroy();
+
+ SpawnedObjects.Clear();
+ IsPopulated = false;
+ }
+}
diff --git a/engine/Sandbox.Engine/Scene/Components/Clutter/Scatterer.cs b/engine/Sandbox.Engine/Scene/Components/Clutter/Scatterer.cs
new file mode 100644
index 00000000..568c66dc
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/Components/Clutter/Scatterer.cs
@@ -0,0 +1,204 @@
+namespace Sandbox.Clutter;
+
+///
+/// Represents a single clutter instance to be spawned.
+///
+public struct ClutterInstance
+{
+ public Transform Transform { get; set; }
+ public ClutterEntry Entry { get; set; }
+ public readonly bool IsModel => Entry is { Model: not null, Prefab: null };
+}
+
+///
+/// Base class to override if you want to create custom scatterer logic.
+/// Provides utility methods for entry selection and common operations.
+///
+[Expose]
+public abstract class Scatterer
+{
+ [Hide]
+ protected Random Random { get; private set; }
+
+ ///
+ /// Generates clutter instances for the given bounds.
+ /// The Random property is initialized before this is called.
+ ///
+ /// World-space bounds to scatter within
+ /// The clutter containing objects to scatter
+ /// Scene to use for tracing (null falls back to Game.ActiveScene)
+ /// Collection of clutter instances to spawn
+ protected abstract List Generate( BBox bounds, ClutterDefinition clutter, Scene scene = null );
+
+ ///
+ /// Public entry point for scattering. Creates Random from seed and calls Generate().
+ ///
+ /// World-space bounds to scatter within
+ /// The clutter containing objects to scatter
+ /// Seed for deterministic random generation
+ /// Scene to use for tracing (required in editor mode)
+ /// Collection of clutter instances to spawn
+ public List Scatter( BBox bounds, ClutterDefinition clutter, int seed, Scene scene = null )
+ {
+ Random = new Random( seed );
+
+ return Generate( bounds, clutter, scene );
+ }
+
+ ///
+ /// Generates a hash from all serializable fields and properties using TypeLibrary.
+ /// Override this if you need custom hash generation logic.
+ ///
+ public override int GetHashCode()
+ {
+ HashCode hash = new();
+ var typeDesc = Game.TypeLibrary.GetType( GetType() );
+
+ if ( typeDesc == null )
+ return base.GetHashCode();
+
+ hash.Add( GetType().Name );
+
+ foreach ( var property in typeDesc.Properties )
+ {
+ if ( !property.HasAttribute() )
+ continue;
+
+ var value = property.GetValue( this );
+ HashValue( ref hash, value );
+ }
+
+ return hash.ToHashCode();
+ }
+
+ private static void HashValue( ref HashCode hash, object value )
+ {
+ if ( value == null )
+ {
+ hash.Add( 0 );
+ return;
+ }
+
+ if ( value is System.Collections.IEnumerable enumerable && value is not string )
+ {
+ foreach ( var item in enumerable )
+ {
+ HashValue( ref hash, item );
+ }
+ return;
+ }
+
+ hash.Add( value.GetHashCode() );
+ }
+
+ ///
+ /// Selects a random entry from the clutter based on weights.
+ /// Returns null if no valid entries exist.
+ ///
+ protected ClutterEntry GetRandomEntry( ClutterDefinition clutter )
+ {
+ if ( clutter.IsEmpty )
+ return null;
+
+ var totalWeight = 0f;
+ foreach ( var entry in clutter.Entries )
+ {
+ if ( entry?.HasAsset is true && entry.Weight > 0 )
+ totalWeight += entry.Weight;
+ }
+
+ if ( totalWeight is 0 ) return null;
+
+ var randomValue = Random.Float( 0f, totalWeight );
+ var currentWeight = 0f;
+
+ foreach ( var entry in clutter.Entries )
+ {
+ if ( entry?.HasAsset is not true || entry.Weight <= 0 )
+ continue;
+
+ currentWeight += entry.Weight;
+ if ( randomValue <= currentWeight )
+ return entry;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Creates a rotation aligned to a surface normal with random yaw.
+ ///
+ protected static Rotation GetAlignedRotation( Vector3 normal, float yawDegrees )
+ {
+ var alignToSurface = Rotation.FromToRotation( Vector3.Up, normal );
+ var yawRotation = Rotation.FromAxis( normal, yawDegrees );
+ return yawRotation * alignToSurface;
+ }
+
+ ///
+ /// Helper to perform a ground trace at a position.
+ ///
+ protected static SceneTraceResult TraceGround( Scene scene, Vector3 position )
+ {
+ // Use scene bounds to determine trace extent
+ var sceneBounds = scene.GetBounds();
+ var traceStart = position.WithZ( sceneBounds.Maxs.z );
+ var traceEnd = position.WithZ( sceneBounds.Mins.z );
+
+ return scene.Trace
+ .Ray( traceStart, traceEnd )
+ .WithoutTags( "player", "trigger", "clutter" )
+ .Run();
+ }
+
+ ///
+ /// Generates a deterministic seed from tile coordinates and base seed.
+ /// Use this to create unique seeds for different tiles.
+ ///
+ public static int GenerateSeed( int baseSeed, int x, int y )
+ {
+ int seed = baseSeed;
+ seed = (seed * 397) ^ x;
+ seed = (seed * 397) ^ y;
+ return seed;
+ }
+
+ ///
+ /// Calculates the number of points to scatter based on density and area.
+ /// Caps at maxPoints to prevent engine freezing.
+ ///
+ /// Bounds to scatter in
+ /// Points per square meter
+ /// Maximum points to cap at (default 10000)
+ /// Number of points to generate
+ protected int CalculatePointCount( BBox bounds, float density, int maxPoints = 10000 )
+ {
+ // Convert bounds from engine units (inches) to meters
+ // 1 inch = 0.0254 meters
+ var widthMeters = bounds.Size.x.InchToMeter();
+ var depthMeters = bounds.Size.y.InchToMeter();
+ var areaSquareMeters = widthMeters * depthMeters;
+
+ var desiredCount = areaSquareMeters * density / 10f;
+
+ // Handle fractional points probabilistically
+ // 1.3 points = 1 guaranteed + 30% chance of 1 more
+ var guaranteedPoints = (int)desiredCount;
+ var fractionalPart = desiredCount - guaranteedPoints;
+
+ var finalCount = guaranteedPoints;
+ if ( Random.Float( 0f, 1f ) < fractionalPart )
+ {
+ finalCount++;
+ }
+
+ var clampedCount = Math.Clamp( finalCount, 0, maxPoints );
+
+ if ( desiredCount > maxPoints )
+ {
+ Log.Warning( $"Scatterer: Density would generate {desiredCount:F0} points, capped to {maxPoints} to prevent freezing." );
+ }
+
+ return clampedCount;
+ }
+}
diff --git a/engine/Sandbox.Engine/Scene/Components/Clutter/SimpleScatterer.cs b/engine/Sandbox.Engine/Scene/Components/Clutter/SimpleScatterer.cs
new file mode 100644
index 00000000..3b2b3136
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/Components/Clutter/SimpleScatterer.cs
@@ -0,0 +1,73 @@
+namespace Sandbox.Clutter;
+
+[Expose]
+public class SimpleScatterer : Scatterer
+{
+ ///
+ /// Scale range for spawned objects.
+ ///
+ [Property]
+ public RangedFloat Scale { get; set; } = new RangedFloat( 0.8f, 1.2f );
+
+ ///
+ /// Points per square meter. 0.05 = sparse trees, 0.5 = dense grass.
+ ///
+ [Property, Range( 0.001f, 2f )]
+ public float Density { get; set; } = 0.05f;
+
+ [Property, Group( "Placement" )]
+ public bool PlaceOnGround { get; set; } = true;
+
+ [Property, Group( "Placement" ), ShowIf( nameof( PlaceOnGround ), true )]
+ public float HeightOffset { get; set; }
+
+ [Property, Group( "Placement" ), ShowIf( nameof( PlaceOnGround ), true )]
+ public bool AlignToNormal { get; set; }
+
+ protected override List Generate( BBox bounds, ClutterDefinition clutter, Scene scene = null )
+ {
+ scene ??= Game.ActiveScene;
+ if ( scene == null || clutter == null )
+ return [];
+
+ var pointCount = CalculatePointCount( bounds, Density );
+ var instances = new List( pointCount );
+
+ for ( int i = 0; i < pointCount; i++ )
+ {
+ var point = new Vector3(
+ bounds.Mins.x + Random.Float( bounds.Size.x ),
+ bounds.Mins.y + Random.Float( bounds.Size.y ),
+ 0f
+ );
+
+ var scale = Random.Float( Scale.Min, Scale.Max );
+ var yaw = Random.Float( 0f, 360f );
+ var rotation = Rotation.FromYaw( yaw );
+
+ if ( PlaceOnGround )
+ {
+ var trace = TraceGround( scene, point );
+ if ( !trace.Hit )
+ continue;
+
+ point = trace.HitPosition + trace.Normal * HeightOffset;
+ rotation = AlignToNormal
+ ? GetAlignedRotation( trace.Normal, yaw )
+ : Rotation.FromYaw( yaw );
+ }
+
+ var entry = GetRandomEntry( clutter );
+ if ( entry == null )
+ continue;
+
+ instances.Add( new ClutterInstance
+ {
+ Transform = new Transform( point, rotation, scale ),
+ Entry = entry
+ } );
+ }
+
+ return instances;
+ }
+}
diff --git a/engine/Sandbox.Engine/Scene/Components/Clutter/TerrainScatterer.cs b/engine/Sandbox.Engine/Scene/Components/Clutter/TerrainScatterer.cs
new file mode 100644
index 00000000..b6a0416f
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/Components/Clutter/TerrainScatterer.cs
@@ -0,0 +1,436 @@
+using System.Text.Json.Serialization;
+
+namespace Sandbox.Clutter;
+
+///
+/// Maps an clutter entry to a slope angle range.
+///
+[Expose]
+public class SlopeMapping
+{
+ ///
+ /// Minimum slope angle (degrees) for this entry.
+ ///
+ [Property, Range( 0, 90 )]
+ public float MinAngle { get; set; } = 0f;
+
+ ///
+ /// Maximum slope angle (degrees) for this entry.
+ ///
+ [Property, Range( 0, 90 )]
+ public float MaxAngle { get; set; } = 45f;
+
+ ///
+ /// Which clutter entry to use for this slope range.
+ ///
+ [Property]
+ [Title( "Entry" )]
+ [Editor( "ClutterEntryPicker" )]
+ public int EntryIndex { get; set; } = 0;
+
+ public override int GetHashCode() => HashCode.Combine( MinAngle, MaxAngle, EntryIndex );
+}
+
+///
+/// Scatterer that filters and selects assets based on the slope angle of the surface.
+/// Useful for placing different vegetation or rocks on flat vs steep terrain.
+///
+[Expose]
+public class SlopeScatterer : Scatterer
+{
+ ///
+ /// Scale range for spawned objects.
+ ///
+ [Property]
+ public RangedFloat Scale { get; set; } = new RangedFloat( 0.8f, 1.2f );
+
+ ///
+ /// Points per square meter (density).
+ ///
+ [Property, Range( 0.001f, 10f )]
+ public float Density { get; set; } = 0.1f;
+
+ ///
+ /// Offset from ground surface.
+ ///
+ [Property, Group( "Placement" )]
+ public float HeightOffset { get; set; } = 0f;
+
+ ///
+ /// Align objects to surface normal.
+ ///
+ [Property, Group( "Placement" )]
+ public bool AlignToNormal { get; set; } = false;
+
+ ///
+ /// Define which entries spawn at which slope angles.
+ ///
+ [Property, Group( "Slope Mappings" )]
+ public List Mappings { get; set; } = new();
+
+ ///
+ /// Use random clutter entry if no slope mapping matches.
+ ///
+ [Property]
+ public bool UseFallback { get; set; } = true;
+
+ protected override List Generate( BBox bounds, ClutterDefinition clutter, Scene scene = null )
+ {
+ scene ??= Game.ActiveScene;
+ if ( scene == null || clutter == null || clutter.IsEmpty )
+ return [];
+
+ var pointCount = CalculatePointCount( bounds, Density );
+ var instances = new List( pointCount );
+
+ for ( int i = 0; i < pointCount; i++ )
+ {
+ var point = new Vector3(
+ bounds.Mins.x + Random.Float( bounds.Size.x ),
+ bounds.Mins.y + Random.Float( bounds.Size.y ),
+ 0f
+ );
+
+ // Trace to ground
+ var trace = TraceGround( scene, point );
+ if ( !trace.Hit )
+ continue;
+
+ // Calculate slope angle
+ var normal = trace.Normal;
+ var slopeAngle = Vector3.GetAngle( Vector3.Up, normal );
+
+ var entry = GetEntryForSlope( clutter, slopeAngle );
+ if ( entry == null )
+ {
+ if ( UseFallback )
+ {
+ entry = GetRandomEntry( clutter );
+ }
+ if ( entry == null )
+ continue;
+ }
+
+ // Setup transform
+ var scale = Random.Float( Scale.Min, Scale.Max );
+ var yaw = Random.Float( 0f, 360f );
+ var rotation = AlignToNormal
+ ? GetAlignedRotation( normal, yaw )
+ : Rotation.FromYaw( yaw );
+
+ var position = trace.HitPosition + normal * HeightOffset;
+
+ instances.Add( new ClutterInstance
+ {
+ Transform = new Transform( position, rotation, scale ),
+ Entry = entry
+ } );
+ }
+
+ return instances;
+ }
+
+ ///
+ /// Finds an entry that matches the given slope angle based on mappings.
+ ///
+ private ClutterEntry GetEntryForSlope( ClutterDefinition clutter, float slopeAngle )
+ {
+ if ( Mappings is null or { Count: 0 } )
+ return GetRandomEntry( clutter );
+
+ var matchCount = 0;
+ foreach ( var m in Mappings )
+ {
+ if ( slopeAngle >= m.MinAngle && slopeAngle <= m.MaxAngle )
+ matchCount++;
+ }
+
+ if ( matchCount is 0 )
+ return null;
+
+ // Pick a random index within matching mappings
+ var randomIndex = Random.Int( 0, matchCount - 1 );
+ var currentIndex = 0;
+
+ foreach ( var m in Mappings )
+ {
+ if ( slopeAngle >= m.MinAngle && slopeAngle <= m.MaxAngle )
+ {
+ if ( currentIndex == randomIndex )
+ {
+ if ( m.EntryIndex >= 0 && m.EntryIndex < clutter.Entries.Count )
+ {
+ var entry = clutter.Entries[m.EntryIndex];
+ if ( entry?.HasAsset is true )
+ return entry;
+ }
+ break;
+ }
+ currentIndex++;
+ }
+ }
+
+ return null;
+ }
+}
+
+///
+/// Maps a terrain material to a list of clutter entries that can spawn on it.
+///
+[Expose]
+public class TerrainMaterialMapping
+{
+ ///
+ /// The terrain material to match.
+ ///
+ [Property]
+ public TerrainMaterial Material { get; set; }
+
+ ///
+ /// Indices of clutter entries that can spawn on this material.
+ ///
+ [Property]
+ [Title( "Entry Indices" )]
+ public List EntryIndices { get; set; } = [];
+
+ public override int GetHashCode()
+ {
+ var hash = new HashCode();
+ hash.Add( Material?.GetHashCode() ?? 0 );
+ foreach ( var index in EntryIndices )
+ hash.Add( index );
+ return hash.ToHashCode();
+ }
+}
+
+///
+/// Scatterer that selects assets based on the terrain material at the hit position.
+/// Useful for placing different vegetation on different terrain textures (grass, dirt, rock, etc).
+///
+[Expose]
+public class TerrainMaterialScatterer : Scatterer
+{
+ ///
+ /// Scale range for spawned objects.
+ ///
+ [Property]
+ public RangedFloat Scale { get; set; } = new RangedFloat( 0.8f, 1.2f );
+
+ ///
+ /// Points per square meter (density).
+ ///
+ [Property, Range( 0.001f, 10f )]
+ public float Density { get; set; } = 0.1f;
+
+ ///
+ /// Offset from ground surface.
+ ///
+ [Property, Group( "Placement" )]
+ public float HeightOffset { get; set; } = 0f;
+
+ ///
+ /// Align objects to surface normal.
+ ///
+ [Property, Group( "Placement" )]
+ public bool AlignToNormal { get; set; } = false;
+
+ ///
+ /// Apply random rotation around vertical axis.
+ ///
+ [Property, Group( "Placement" )]
+ public bool RandomYaw { get; set; } = true;
+
+ ///
+ /// Define which entries spawn on which terrain materials.
+ ///
+ [Property, Group( "Material Mappings" )]
+ public List Mappings { get; set; } = new();
+
+ ///
+ /// Use random clutter entry if no material mapping matches or no terrain is present.
+ ///
+ [Property, Group( "Fallback" )]
+ public bool UseFallback { get; set; } = true;
+
+ ///
+ /// Cached terrain reference to avoid repeated GetComponent calls within same tile.
+ ///
+ [JsonIgnore, Hide]
+ private Terrain _cachedTerrain;
+
+ [JsonIgnore, Hide]
+ private GameObject _cachedTerrainObject;
+
+ protected override List Generate( BBox bounds, ClutterDefinition clutter, Scene scene = null )
+ {
+ scene ??= Game.ActiveScene;
+ if ( scene == null || clutter == null || clutter.IsEmpty )
+ return [];
+
+ // Clear terrain cache for new generation
+ _cachedTerrain = null;
+ _cachedTerrainObject = null;
+
+ var pointCount = CalculatePointCount( bounds, Density );
+ var instances = new List( pointCount );
+
+ for ( int i = 0; i < pointCount; i++ )
+ {
+ var point = new Vector3(
+ bounds.Mins.x + Random.Float( bounds.Size.x ),
+ bounds.Mins.y + Random.Float( bounds.Size.y ),
+ 0f
+ );
+
+ // Trace to ground
+ var trace = TraceGround( scene, point );
+ if ( !trace.Hit )
+ continue;
+
+ var terrain = GetTerrainFromTrace( trace );
+ if ( terrain == null )
+ {
+ if ( UseFallback )
+ {
+ var fallbackEntry = GetRandomEntry( clutter );
+ if ( fallbackEntry != null )
+ {
+ instances.Add( CreateInstance( trace, fallbackEntry ) );
+ }
+ }
+ continue;
+ }
+
+ // Query terrain material at hit position
+ var materialInfo = terrain.GetMaterialAtWorldPosition( trace.HitPosition );
+ if ( !materialInfo.HasValue || materialInfo.Value.IsHole )
+ continue;
+
+ // Find matching entry from material mappings
+ var entry = GetEntryForMaterial( clutter, materialInfo.Value );
+ if ( entry == null )
+ {
+ if ( UseFallback )
+ {
+ entry = GetRandomEntry( clutter );
+ }
+ if ( entry == null )
+ continue;
+ }
+
+ instances.Add( CreateInstance( trace, entry ) );
+ }
+
+ return instances;
+ }
+
+ private ClutterInstance CreateInstance( SceneTraceResult trace, ClutterEntry entry )
+ {
+ var scale = Random.Float( Scale.Min, Scale.Max );
+ var normal = trace.Normal;
+ var yaw = RandomYaw ? Random.Float( 0f, 360f ) : 0f;
+
+ Rotation rotation;
+ if ( AlignToNormal )
+ {
+ rotation = GetAlignedRotation( normal, yaw );
+ }
+ else
+ {
+ rotation = Rotation.FromYaw( yaw );
+ }
+
+ var position = trace.HitPosition + normal * HeightOffset;
+
+ return new ClutterInstance
+ {
+ Transform = new Transform( position, rotation, scale ),
+ Entry = entry
+ };
+ }
+
+ ///
+ /// Gets the Terrain component from a trace result, with caching.
+ ///
+ private Terrain GetTerrainFromTrace( SceneTraceResult trace )
+ {
+ var hitObject = trace.GameObject;
+ if ( hitObject == null )
+ return null;
+
+ // Use cached terrain if hitting same object
+ if ( _cachedTerrainObject == hitObject )
+ return _cachedTerrain;
+
+ // Cache the terrain lookup
+ _cachedTerrainObject = hitObject;
+ _cachedTerrain = hitObject.Components.Get();
+
+ return _cachedTerrain;
+ }
+
+ ///
+ /// Finds an entry that matches the terrain material at the given position.
+ ///
+ private ClutterEntry GetEntryForMaterial( ClutterDefinition clutter, Terrain.TerrainMaterialInfo materialInfo )
+ {
+ if ( Mappings is null or { Count: 0 } )
+ return null;
+
+ // Get the dominant material
+ var dominantMaterial = materialInfo.GetDominantMaterial();
+ if ( dominantMaterial is null )
+ return null;
+
+ // Find mapping for this material
+ var mapping = Mappings.FirstOrDefault( m => m.Material == dominantMaterial );
+ if ( mapping is null || mapping.EntryIndices is null or { Count: 0 } )
+ return null;
+
+ var totalWeight = 0f;
+ foreach ( var index in mapping.EntryIndices )
+ {
+ if ( index >= 0 && index < clutter.Entries.Count )
+ {
+ var entry = clutter.Entries[index];
+ if ( entry?.HasAsset is true && entry.Weight > 0 )
+ totalWeight += entry.Weight;
+ }
+ }
+
+ if ( totalWeight <= 0 )
+ return null;
+
+ // Pick a weighted random entry
+ var randomValue = Random.Float( 0f, totalWeight );
+ var currentWeight = 0f;
+
+ foreach ( var index in mapping.EntryIndices )
+ {
+ if ( index >= 0 && index < clutter.Entries.Count )
+ {
+ var entry = clutter.Entries[index];
+ if ( entry?.HasAsset is true && entry.Weight > 0 )
+ {
+ currentWeight += entry.Weight;
+ if ( randomValue <= currentWeight )
+ return entry;
+ }
+ }
+ }
+
+ // Fallback: return last valid entry
+ for ( var i = mapping.EntryIndices.Count - 1; i >= 0; i-- )
+ {
+ var index = mapping.EntryIndices[i];
+ if ( index >= 0 && index < clutter.Entries.Count )
+ {
+ var entry = clutter.Entries[index];
+ if ( entry?.HasAsset is true && entry.Weight > 0 )
+ return entry;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/engine/Sandbox.Engine/Scene/GameObjectSystems/ClutterGridSystem.Storage.cs b/engine/Sandbox.Engine/Scene/GameObjectSystems/ClutterGridSystem.Storage.cs
new file mode 100644
index 00000000..a6772d61
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/GameObjectSystems/ClutterGridSystem.Storage.cs
@@ -0,0 +1,187 @@
+using System;
+using System.Collections.Generic;
+
+namespace Sandbox.Clutter;
+
+public sealed partial class ClutterGridSystem
+{
+ ///
+ /// Manages storage and serialization of painted clutter instances.
+ /// Uses binary serialization via BlobData for efficient storage.
+ ///
+ public sealed class ClutterStorage : BlobData
+ {
+ public override int Version => 1;
+
+ public record struct Instance( Vector3 Position, Rotation Rotation, float Scale = 1f );
+
+ private Dictionary> _instances = [];
+
+ public ClutterStorage() { }
+
+ ///
+ /// Gets the total number of instances across all models.
+ ///
+ public int TotalCount
+ {
+ get
+ {
+ var count = 0;
+ foreach ( var list in _instances.Values )
+ count += list.Count;
+ return count;
+ }
+ }
+
+ ///
+ /// Gets all model paths that have instances.
+ ///
+ public IEnumerable ModelPaths => _instances.Keys;
+
+ ///
+ /// Gets instances for a specific model path.
+ ///
+ public IReadOnlyList GetInstances( string modelPath )
+ {
+ if ( _instances.TryGetValue( modelPath, out var list ) )
+ return list;
+
+ return [];
+ }
+
+ ///
+ /// Gets all instances grouped by model path.
+ ///
+ public IReadOnlyDictionary> GetAllInstances() => _instances;
+
+ ///
+ /// Adds a single instance for a model.
+ ///
+ public void AddInstance( string modelPath, Vector3 position, Rotation rotation, float scale = 1f )
+ {
+ if ( string.IsNullOrEmpty( modelPath ) ) return;
+
+ if ( !_instances.TryGetValue( modelPath, out var list ) )
+ {
+ list = [];
+ _instances[modelPath] = list;
+ }
+
+ list.Add( new Instance( position, rotation, scale ) );
+ }
+
+ ///
+ /// Adds multiple instances for a model.
+ ///
+ public void AddInstances( string modelPath, IEnumerable instances )
+ {
+ if ( string.IsNullOrEmpty( modelPath ) ) return;
+
+ if ( !_instances.TryGetValue( modelPath, out var list ) )
+ {
+ list = [];
+ _instances[modelPath] = list;
+ }
+
+ list.AddRange( instances );
+ }
+
+ ///
+ /// Erases all instances within a radius of a position.
+ ///
+ public int Erase( Vector3 position, float radius )
+ {
+ if ( _instances.Count == 0 ) return 0;
+
+ var radiusSquared = radius * radius;
+ var totalRemoved = 0;
+
+ foreach ( var list in _instances.Values )
+ {
+ var removed = list.RemoveAll( i => i.Position.DistanceSquared( position ) <= radiusSquared );
+ totalRemoved += removed;
+ }
+
+ return totalRemoved;
+ }
+
+ ///
+ /// Clears all instances for a specific model.
+ ///
+ public bool ClearModel( string modelPath )
+ {
+ return _instances.Remove( modelPath );
+ }
+
+ ///
+ /// Clears all instances.
+ ///
+ public void ClearAll()
+ {
+ _instances.Clear();
+ }
+
+ ///
+ /// Serialize to binary format.
+ ///
+ public override void Serialize( ref Writer writer )
+ {
+ // Write model count
+ writer.Stream.Write( _instances.Count );
+
+ foreach ( var (modelPath, instances) in _instances )
+ {
+ // Write model path
+ writer.Stream.Write( modelPath );
+
+ // Write instance count
+ writer.Stream.Write( instances.Count );
+
+ // Write each instance
+ foreach ( var instance in instances )
+ {
+ writer.Stream.Write( instance.Position );
+ writer.Stream.Write( instance.Rotation );
+ writer.Stream.Write( instance.Scale );
+ }
+ }
+ }
+
+ ///
+ /// Deserialize from binary format.
+ ///
+ public override void Deserialize( ref Reader reader )
+ {
+ _instances.Clear();
+
+ // Read model count
+ var modelCount = reader.Stream.Read();
+
+ for ( int m = 0; m < modelCount; m++ )
+ {
+ // Read model path
+ var modelPath = reader.Stream.Read();
+
+ // Read instance count
+ var instanceCount = reader.Stream.Read();
+
+ var instances = new List( instanceCount );
+
+ // Read each instance
+ for ( int i = 0; i < instanceCount; i++ )
+ {
+ var position = reader.Stream.Read();
+ var rotation = reader.Stream.Read();
+ var scale = reader.Stream.Read();
+
+ instances.Add( new Instance( position, rotation, scale ) );
+ }
+
+ if ( !string.IsNullOrEmpty( modelPath ) && instances.Count > 0 )
+ {
+ _instances[modelPath] = instances;
+ }
+ }
+ }
+ }
+}
diff --git a/engine/Sandbox.Engine/Scene/GameObjectSystems/ClutterGridSystem.cs b/engine/Sandbox.Engine/Scene/GameObjectSystems/ClutterGridSystem.cs
new file mode 100644
index 00000000..1bc96d72
--- /dev/null
+++ b/engine/Sandbox.Engine/Scene/GameObjectSystems/ClutterGridSystem.cs
@@ -0,0 +1,422 @@
+namespace Sandbox.Clutter;
+
+///
+/// Game object system that manages clutter generation.
+/// Handles infinite streaming layers and executes generation jobs.
+///
+public sealed partial class ClutterGridSystem : GameObjectSystem
+{
+ ///
+ /// Mapping of clutter components to their respective layers
+ ///
+ private readonly Dictionary _componentToLayer = [];
+
+ private const int MAX_JOBS_PER_FRAME = 8;
+ private const int MAX_PENDING_JOBS = 100;
+
+ private readonly List _pendingJobs = [];
+ private readonly HashSet _pendingTiles = [];
+ private readonly HashSet _subscribedTerrains = [];
+ private Vector3 _lastCameraPosition;
+
+ ///
+ /// Storage for painted clutter model instances.
+ /// Serialized with the scene - this is the source of truth for painted clutter.
+ ///
+ [Property]
+ public ClutterStorage Storage { get; set; } = new();
+
+ ///
+ /// Layer for rendering painted model instances from Storage.
+ /// This is transient - rebuilt from Storage on scene load.
+ ///
+ private ClutterLayer _painted;
+
+ private bool _dirty = false;
+
+ public ClutterGridSystem( Scene scene ) : base( scene )
+ {
+ Listen( Stage.FinishUpdate, 0, OnUpdate, "ClutterGridSystem.Update" );
+ Listen( Stage.SceneLoaded, 0, RebuildPaintedLayer, "ClutterGridSystem.RestorePainted" );
+ }
+
+ ///
+ /// Check for new terrains, queue generation/cleanup jobs, and process pending jobs.
+ ///
+ private void OnUpdate()
+ {
+ var camera = GetActiveCamera();
+ if ( camera == null )
+ return;
+
+ _lastCameraPosition = camera.WorldPosition;
+
+ SubscribeToTerrains();
+ UpdateInfiniteLayers( _lastCameraPosition );
+ ProcessJobs();
+
+ if ( _dirty )
+ {
+ RebuildPaintedLayer();
+ _dirty = false;
+ }
+ }
+
+ private void SubscribeToTerrains()
+ {
+ foreach ( var terrain in Scene.GetAllComponents() )
+ {
+ if ( _subscribedTerrains.Add( terrain ) )
+ {
+ terrain.OnTerrainModified += OnTerrainModified;
+ }
+ }
+
+ _subscribedTerrains.RemoveWhere( t => !t.IsValid() );
+ }
+
+ private void UpdateActiveComponents( List components, Vector3 cameraPosition )
+ {
+ foreach ( var component in components )
+ {
+ var settings = component.GetCurrentSettings();
+ if ( !settings.IsValid )
+ continue;
+
+ var layer = GetOrCreateLayer( component, settings );
+ layer.UpdateSettings( settings );
+
+ foreach ( var job in layer.UpdateTiles( cameraPosition ) )
+ QueueJob( job );
+ }
+ }
+
+ private void RemoveInactiveComponents( List activeInfiniteComponents )
+ {
+ var toRemove = _componentToLayer.Keys
+ .Where( c => !c.IsValid() || (c.Infinite && !activeInfiniteComponents.Contains( c )) )
+ .ToList();
+
+ foreach ( var component in toRemove )
+ {
+ _componentToLayer[component].ClearAllTiles();
+ _componentToLayer.Remove( component );
+ }
+ }
+
+ private void UpdateInfiniteLayers( Vector3 cameraPosition )
+ {
+ var activeComponents = Scene.GetAllComponents()
+ .Where( c => c.Active && c.Infinite )
+ .ToList();
+
+ RemoveInactiveComponents( activeComponents );
+ UpdateActiveComponents( activeComponents, cameraPosition );
+ }
+
+ ///
+ /// Queues a generation job for processing.
+ ///
+ internal void QueueJob( ClutterGenerationJob job )
+ {
+ if ( !job.Parent.IsValid() )
+ return;
+
+ // Prevent duplicate jobs for the same tile
+ if ( job.Tile is not null && !_pendingTiles.Add( job.Tile ) )
+ return;
+
+ _pendingJobs.Add( job );
+ }
+
+ ///
+ /// Removes a tile from pending set (called when tile is destroyed).
+ ///
+ internal void RemovePendingTile( ClutterTile tile )
+ {
+ _pendingTiles.Remove( tile );
+ _pendingJobs.RemoveAll( job => job.Tile == tile );
+ }
+
+ ///
+ /// Clears all tiles for a specific component.
+ ///
+ public void ClearComponent( ClutterComponent component )
+ {
+ // Remove any pending jobs for this component (both tile and volume jobs)
+ _pendingJobs.RemoveAll( job => job.Parent == component.GameObject );
+
+ if ( _componentToLayer.Remove( component, out var layer ) )
+ {
+ layer.ClearAllTiles();
+ }
+ }
+
+ ///
+ /// Invalidates the tile at the given world position for a component, causing it to regenerate.
+ ///
+ public void InvalidateTileAt( ClutterComponent component, Vector3 worldPosition )
+ {
+ if ( _componentToLayer.TryGetValue( component, out var layer ) )
+ {
+ layer.InvalidateTile( worldPosition );
+ }
+ }
+
+ ///
+ /// Invalidates all tiles within the given bounds for a component, causing them to regenerate.
+ ///
+ public void InvalidateTilesInBounds( ClutterComponent component, BBox bounds )
+ {
+ if ( _componentToLayer.TryGetValue( component, out var layer ) )
+ {
+ layer.InvalidateTilesInBounds( bounds );
+ }
+ }
+
+ ///
+ /// Invalidates all tiles within the given bounds for ALL infinite clutter components.
+ /// Useful for terrain painting where you want to refresh all clutter layers.
+ ///
+ public void InvalidateTilesInBounds( BBox bounds )
+ {
+ foreach ( var layer in _componentToLayer.Values )
+ {
+ layer.InvalidateTilesInBounds( bounds );
+ }
+ }
+
+ private void OnTerrainModified( Terrain.SyncFlags flags, RectInt region )
+ {
+ var bounds = TerrainRegionToWorldBounds( _subscribedTerrains.First(), region );
+ InvalidateTilesInBounds( bounds );
+ }
+
+ private static BBox TerrainRegionToWorldBounds( Terrain terrain, RectInt region )
+ {
+ var terrainTransform = terrain.WorldTransform;
+ var storage = terrain.Storage;
+
+ // Convert pixel coordinates to normalized (0-1) coordinates
+ var minNorm = new Vector2(
+ (float)region.Left / storage.Resolution,
+ (float)region.Top / storage.Resolution
+ );
+ var maxNorm = new Vector2(
+ (float)region.Right / storage.Resolution,
+ (float)region.Bottom / storage.Resolution
+ );
+
+ var terrainSize = storage.TerrainSize;
+ var minLocal = new Vector3( minNorm.x * terrainSize, minNorm.y * terrainSize, -1000f );
+ var maxLocal = new Vector3( maxNorm.x * terrainSize, maxNorm.y * terrainSize, 1000f );
+
+ var minWorld = terrainTransform.PointToWorld( minLocal );
+ var maxWorld = terrainTransform.PointToWorld( maxLocal );
+
+ return new BBox( minWorld, maxWorld );
+ }
+
+ private CameraComponent GetActiveCamera()
+ {
+ return Scene.IsEditor
+ ? Scene.Camera
+ : Scene.Camera; // Figure out a way to grab editor camera
+ }
+
+ internal ClutterLayer GetOrCreateLayer( ClutterComponent component, ClutterSettings settings )
+ {
+ if ( _componentToLayer.TryGetValue( component, out var layer ) )
+ return layer;
+
+ layer = new ClutterLayer( settings, component.GameObject, this );
+ _componentToLayer[component] = layer;
+ return layer;
+ }
+
+ private int _lastSortedJobCount = 0;
+
+ private void ProcessJobs()
+ {
+ if ( _pendingJobs.Count == 0 )
+ return;
+
+ // Track which layers had tiles populated
+ HashSet layersToRebuild = [];
+
+ _pendingJobs.RemoveAll( job =>
+ !job.Parent.IsValid() ||
+ job.Tile?.IsPopulated == true
+ );
+
+ // Only sort when job count changes significantly (avoid sorting every frame)
+ if ( Math.Abs( _pendingJobs.Count - _lastSortedJobCount ) > 50 || _lastSortedJobCount == 0 )
+ {
+ _pendingJobs.Sort( ( a, b ) =>
+ {
+ // Use tile bounds for infinite mode, job bounds for volume mode
+ var distA = a.Tile != null
+ ? a.Tile.Bounds.Center.Distance( _lastCameraPosition )
+ : a.Bounds.Center.Distance( _lastCameraPosition );
+ var distB = b.Tile != null
+ ? b.Tile.Bounds.Center.Distance( _lastCameraPosition )
+ : b.Bounds.Center.Distance( _lastCameraPosition );
+ return distA.CompareTo( distB );
+ } );
+ _lastSortedJobCount = _pendingJobs.Count;
+ }
+
+ // Process nearest tiles first
+ int processed = 0;
+ while ( processed < MAX_JOBS_PER_FRAME && _pendingJobs.Count > 0 )
+ {
+ var job = _pendingJobs[0];
+ _pendingJobs.RemoveAt( 0 );
+
+ if ( job.Tile != null )
+ _pendingTiles.Remove( job.Tile );
+
+ // Execute if still valid and not populated
+ if ( job.Parent.IsValid() && job.Tile?.IsPopulated != true )
+ {
+ job.Execute();
+ processed++;
+
+ if ( job.Layer != null )
+ layersToRebuild.Add( job.Layer );
+ }
+ }
+
+ // Rebuild batches for layers that had tiles populated
+ foreach ( var layer in layersToRebuild )
+ {
+ layer.RebuildBatches();
+ }
+
+ var infiniteJobs = _pendingJobs.Where( j => j.Tile != null ).ToList();
+ if ( infiniteJobs.Count > MAX_PENDING_JOBS )
+ {
+ var toRemove = infiniteJobs.Skip( MAX_PENDING_JOBS ).ToList();
+ foreach ( var job in toRemove )
+ {
+ _pendingTiles.Remove( job.Tile );
+ _pendingJobs.Remove( job );
+ }
+ }
+ }
+
+ ///
+ /// Paint instance. Rebuilds on next frame update.
+ /// Models are batched, Prefabs become GameObjects.
+ ///
+ public void Paint( ClutterEntry entry, Vector3 pos, Rotation rot, float scale = 1f )
+ {
+ if ( entry == null || !entry.HasAsset ) return;
+
+ if ( entry.Prefab != null )
+ {
+ var go = entry.Prefab.Clone( pos, rot );
+ go.WorldScale = scale;
+ go.SetParent( Scene );
+ go.Tags.Add( "clutter_painted" );
+ }
+ else if ( entry.Model != null )
+ {
+ Storage.AddInstance( entry.Model.ResourcePath, pos, rot, scale );
+ _dirty = true;
+ }
+ }
+
+ ///
+ /// Erase instances. Rebuilds on next frame update.
+ /// Erases both model batches and prefab GameObjects.
+ ///
+ public void Erase( Vector3 pos, float radius )
+ {
+ var radiusSquared = radius * radius;
+ if ( Storage.Erase( pos, radius ) > 0 )
+ {
+ _dirty = true;
+ }
+
+ // Only erase painted prefabs, not streamed/volume clutter
+ var paintedObjects = Scene.FindAllWithTag( "clutter_painted" )
+ .Where( go => go.WorldPosition.DistanceSquared( pos ) <= radiusSquared )
+ .ToList();
+
+ foreach ( var go in paintedObjects )
+ {
+ go.Destroy();
+ }
+ }
+
+ ///
+ /// Clears all painted clutter (both model instances from storage and prefab GameObjects).
+ /// Does not affect clutter owned by ClutterComponent volumes.
+ ///
+ public void ClearAllPainted()
+ {
+ Storage.ClearAll();
+
+ var paintedObjects = Scene.FindAllWithTag( "clutter_painted" ).ToList();
+ foreach ( var go in paintedObjects )
+ {
+ go.Destroy();
+ }
+
+ _painted?.ClearAllTiles();
+ _dirty = false;
+ }
+
+ ///
+ /// Flush painted changes and rebuild visual batches immediately.
+ ///
+ public void Flush()
+ {
+ RebuildPaintedLayer();
+ _dirty = false;
+ }
+
+ ///
+ /// Rebuild the painted clutter layer from stored instances in Storage.
+ ///
+ private void RebuildPaintedLayer()
+ {
+ if ( Storage is null )
+ {
+ Storage = new ClutterStorage();
+ }
+
+ if ( Storage.TotalCount == 0 )
+ {
+ _painted?.ClearAllTiles();
+ return;
+ }
+
+ // Create or reuse painted layer
+ if ( _painted == null )
+ {
+ var settings = new ClutterSettings( 0, new ClutterDefinition() );
+ _painted = new ClutterLayer( settings, null, this );
+ }
+
+ _painted.ClearAllTiles();
+
+ foreach ( var modelPath in Storage.ModelPaths )
+ {
+ var model = ResourceLibrary.Get( modelPath );
+ if ( model == null ) continue;
+
+ foreach ( var instance in Storage.GetInstances( modelPath ) )
+ {
+ _painted.AddModelInstance( Vector2Int.Zero, new()
+ {
+ Transform = new( instance.Position, instance.Rotation, instance.Scale ),
+ Entry = new() { Model = model }
+ } );
+ }
+ }
+
+ _painted.RebuildBatches();
+ }
+}
diff --git a/engine/Sandbox.Engine/Utility/Json/Json.cs b/engine/Sandbox.Engine/Utility/Json/Json.cs
index 6c21142c..0342173b 100644
--- a/engine/Sandbox.Engine/Utility/Json/Json.cs
+++ b/engine/Sandbox.Engine/Utility/Json/Json.cs
@@ -40,6 +40,7 @@ public static partial class Json
options.Converters.Add( new JsonStringEnumConverter( null, true ) );
options.Converters.Add( new BinaryConvert() );
+
options.Converters.Add( new JsonConvertFactory() );
options.Converters.Add( new MovieResourceConverter() );
options.Converters.Add( new AnyOfTypeConverterFactory() );
diff --git a/engine/Sandbox.System/Attributes/LibraryAttribute.cs b/engine/Sandbox.System/Attributes/LibraryAttribute.cs
index 2e65cfbd..10eeea15 100644
--- a/engine/Sandbox.System/Attributes/LibraryAttribute.cs
+++ b/engine/Sandbox.System/Attributes/LibraryAttribute.cs
@@ -2,7 +2,7 @@
namespace Sandbox
{
- [AttributeUsage( AttributeTargets.Class )]
+ [AttributeUsage( AttributeTargets.Class | AttributeTargets.Struct )]
public class LibraryAttribute : System.Attribute, ITitleProvider, IDescriptionProvider, IClassNameProvider, IUninheritable
{
string Internal.ITitleProvider.Value => Title;
diff --git a/engine/Sandbox.System/Utility/AnyOfType.cs b/engine/Sandbox.System/Utility/AnyOfType.cs
index fecae3ab..4023a076 100644
--- a/engine/Sandbox.System/Utility/AnyOfType.cs
+++ b/engine/Sandbox.System/Utility/AnyOfType.cs
@@ -11,6 +11,7 @@ namespace Sandbox;
///
/// Serialization stores the concrete type name alongside the property values
///
+[Library]
public readonly struct AnyOfType where T : class
{
///
diff --git a/game/addons/tools/Code/Scene/ClutterTool/ClutterDefinitionAssetPreview.cs b/game/addons/tools/Code/Scene/ClutterTool/ClutterDefinitionAssetPreview.cs
new file mode 100644
index 00000000..b55e3e1b
--- /dev/null
+++ b/game/addons/tools/Code/Scene/ClutterTool/ClutterDefinitionAssetPreview.cs
@@ -0,0 +1,222 @@
+using Editor.Assets;
+using Sandbox.Clutter;
+using System.Threading.Tasks;
+
+namespace Editor;
+
+///
+/// Preview for ClutterDefinition using the actual clutter system.
+///
+[AssetPreview( "clutter" )]
+public class ClutterDefinitionAssetPreview( Asset asset ) : AssetPreview( asset )
+{
+ private ClutterDefinition _clutter;
+ private GameObject _clutterObject;
+ private GameObject _ground;
+ private TileBoundsGrid _tileBounds;
+ private int _lastHash;
+ private int _currentSeed = 1337;
+
+ public override float PreviewWidgetCycleSpeed => 0.15f;
+
+ public override async Task InitializeAsset()
+ {
+ await base.InitializeAsset();
+
+ _clutter = Asset.LoadResource();
+ if ( _clutter == null ) return;
+
+ using ( Scene.Push() )
+ {
+ CreateGround();
+ CreateTileBounds();
+ CreateClutter();
+ _lastHash = GetHash();
+
+ await Task.Delay( 50 );
+ CalculateSceneBounds();
+ }
+ }
+
+ public override void UpdateScene( float cycle, float timeStep )
+ {
+ if ( _clutter != null )
+ {
+ var currentHash = GetHash();
+ if ( currentHash != _lastHash )
+ {
+ _lastHash = currentHash;
+ RegenerateAsync();
+ }
+ }
+
+ using ( Scene.Push() )
+ {
+ var angle = cycle * 360.0f;
+ var distance = MathX.SphereCameraDistance( SceneSize.Length * 0.5f, Camera.FieldOfView );
+ var aspect = (float)ScreenSize.x / ScreenSize.y;
+ if ( aspect > 1 ) distance *= aspect;
+
+ var rotation = new Angles( 20, 180 + 45 + angle, 0 ).ToRotation();
+ Camera.WorldRotation = rotation;
+ Camera.WorldPosition = SceneCenter + rotation.Forward * -distance;
+ }
+
+ TickScene( timeStep );
+ }
+
+ private async void RegenerateAsync()
+ {
+ using ( Scene.Push() )
+ {
+ _clutterObject?.Destroy();
+ _tileBounds?.Delete();
+ CreateTileBounds();
+ CreateClutter();
+
+ await Task.Delay( 50 );
+ CalculateSceneBounds();
+ }
+ }
+
+ private void CreateClutter()
+ {
+ _clutterObject = new GameObject( true, "ClutterPreview" );
+
+ var component = _clutterObject.Components.Create();
+ component.Clutter = _clutter;
+ component.Mode = ClutterComponent.ClutterMode.Volume;
+ component.Seed = _currentSeed;
+
+ var tileSize = _clutter.TileSize;
+ component.Bounds = new BBox(
+ new Vector3( -tileSize / 2, -tileSize / 2, -100 ),
+ new Vector3( tileSize / 2, tileSize / 2, 100 )
+ );
+ component.Generate();
+ }
+
+ private void CreateGround()
+ {
+ _ground = new GameObject( true, "Ground" );
+ var collider = _ground.Components.Create();
+ collider.Scale = new Vector3( 5000, 5000, 10 );
+ collider.Center = new Vector3( 0, 0, -5 );
+ }
+
+ private void CreateTileBounds()
+ {
+ _tileBounds = new TileBoundsGrid( Scene.SceneWorld, _clutter.TileSize );
+ }
+
+ private void CalculateSceneBounds()
+ {
+ if ( _clutter == null ) return;
+
+ var tileSize = _clutter.TileSize;
+ var bbox = new BBox(
+ new Vector3( -tileSize / 2, -tileSize / 2, -100 ),
+ new Vector3( tileSize / 2, tileSize / 2, 100 )
+ );
+
+ bbox = bbox.AddPoint( Vector3.Zero );
+
+ SceneCenter = bbox.Center;
+ SceneSize = bbox.Size;
+ }
+
+ private int GetHash()
+ {
+ if ( _clutter == null ) return 0;
+
+ var hash = new System.HashCode();
+ hash.Add( _clutter.Entries?.Count ?? 0 );
+ hash.Add( _clutter.TileSize );
+ hash.Add( _clutter.Scatterer.Value?.GetHashCode() ?? 0 );
+
+ if ( _clutter.Entries != null )
+ {
+ foreach ( var entry in _clutter.Entries )
+ {
+ hash.Add( entry?.Weight ?? 0 );
+ hash.Add( entry?.Model?.ResourcePath ?? "" );
+ hash.Add( entry?.Prefab?.GetHashCode() ?? 0 );
+ }
+ }
+
+ return hash.ToHashCode();
+ }
+
+ public override void Dispose()
+ {
+ _clutterObject?.Destroy();
+ _ground?.Destroy();
+ _tileBounds?.Delete();
+ base.Dispose();
+ }
+
+ public override Widget CreateToolbar()
+ {
+ var toolbar = new Widget { Layout = Layout.Row() };
+ toolbar.Layout.Spacing = 8;
+ toolbar.Layout.Margin = 8;
+
+ var randomBtn = new Button( "Randomize", "casino" );
+ randomBtn.Clicked += async () =>
+ {
+ using ( Scene.Push() )
+ {
+ _currentSeed = Game.Random.Next();
+ _clutterObject?.Destroy();
+ _tileBounds?.Delete();
+
+ CreateTileBounds();
+ CreateClutter();
+
+ await Task.Delay( 50 );
+ CalculateSceneBounds();
+ }
+ };
+ randomBtn.ToolTip = "Randomize seed";
+ toolbar.Layout.Add( randomBtn );
+
+ return toolbar;
+ }
+}
+
+///
+/// Custom scene object that renders tile bounds
+///
+internal class TileBoundsGrid : SceneCustomObject
+{
+ private readonly Vertex[] _vertices;
+ private static Material _lineMaterial;
+
+ public TileBoundsGrid( SceneWorld world, float tileSize ) : base( world )
+ {
+ var halfSize = tileSize / 2f;
+ var color = Color.Gray.WithAlpha( 0.5f );
+ var corners = new Vector3[]
+ {
+ new Vector3( -halfSize, -halfSize, 0.5f ),
+ new Vector3( halfSize, -halfSize, 0.5f ),
+ new Vector3( halfSize, halfSize, 0.5f ),
+ new Vector3( -halfSize, halfSize, 0.5f )
+ };
+
+ _vertices = new Vertex[8];
+ for ( int i = 0; i < 4; i++ )
+ {
+ _vertices[i * 2] = new Vertex( corners[i], color );
+ _vertices[i * 2 + 1] = new Vertex( corners[(i + 1) % 4], color );
+ }
+
+ Bounds = new BBox( new Vector3( -halfSize, -halfSize, 0 ), new Vector3( halfSize, halfSize, 10 ) );
+ }
+
+ public override void RenderSceneObject()
+ {
+ _lineMaterial ??= Material.Load( "materials/gizmo/line.vmat" );
+ Graphics.Draw( _vertices.AsSpan(), _vertices.Length, _lineMaterial, Attributes, Graphics.PrimitiveType.Lines );
+ }
+}
diff --git a/game/addons/tools/Code/Scene/ClutterTool/ClutterDefinitionEditor.cs b/game/addons/tools/Code/Scene/ClutterTool/ClutterDefinitionEditor.cs
new file mode 100644
index 00000000..fb830b7f
--- /dev/null
+++ b/game/addons/tools/Code/Scene/ClutterTool/ClutterDefinitionEditor.cs
@@ -0,0 +1,117 @@
+using Sandbox.Clutter;
+
+namespace Editor;
+
+///
+/// Resource editor for ClutterDefinition using feature tabs.
+///
+public class ClutterDefinitionEditor : BaseResourceEditor
+{
+ private ClutterDefinition _resource;
+ private SerializedObject _serialized;
+ private Layout _scattererProperties;
+ private Type _currentScattererType;
+
+ protected override void Initialize( Asset asset, ClutterDefinition resource )
+ {
+ _resource = resource;
+
+ Layout = Layout.Column();
+ Layout.Spacing = 0;
+ Layout.Margin = 0;
+
+ _serialized = resource.GetSerialized();
+ _serialized.OnPropertyChanged += ( prop ) =>
+ {
+ switch ( prop.Name )
+ {
+ case nameof( ClutterDefinition.Entries ):
+ case nameof( ClutterDefinition.TileSizeEnum ):
+ case nameof( ClutterDefinition.TileRadius ):
+ NoteChanged( prop );
+ break;
+
+ case nameof( ClutterDefinition.Scatterer ):
+ var newType = _resource.Scatterer.Value?.GetType();
+ if ( newType != _currentScattererType )
+ {
+ NoteChanged( prop );
+ RebuildScattererProperties();
+ }
+ break;
+ }
+ };
+
+ var tabs = new TabWidget( this );
+ tabs.VerticalSizeMode = SizeMode.CanGrow;
+ tabs.AddPage( "Entries", "grass", CreateEntriesTab( _serialized ) );
+ tabs.AddPage( "Scatterer", "scatter_plot", CreateScattererTab( _serialized ) );
+ tabs.AddPage( "Streaming", "grid_view", CreateStreamingTab( _serialized ) );
+
+ Layout.Add( tabs, 1 );
+ }
+
+ private Widget CreateEntriesTab( SerializedObject serialized )
+ {
+ var container = new Widget( null );
+ container.Layout = Layout.Column();
+ container.VerticalSizeMode = SizeMode.CanGrow;
+
+ var sheet = new ControlSheet();
+ sheet.AddRow( serialized.GetProperty( nameof( ClutterDefinition.Entries ) ) );
+
+ container.Layout.Add( sheet, 1 );
+ return container;
+ }
+
+ private Widget CreateScattererTab( SerializedObject serialized )
+ {
+ var container = new Widget( null );
+ container.Layout = Layout.Column();
+ container.VerticalSizeMode = SizeMode.CanGrow;
+
+ var sheet = new ControlSheet();
+ sheet.AddRow( serialized.GetProperty( nameof( ClutterDefinition.Scatterer ) ) );
+
+ container.Layout.Add( sheet );
+ _scattererProperties = container.Layout.AddColumn();
+ container.Layout.AddStretchCell();
+
+ RebuildScattererProperties();
+
+ return container;
+ }
+
+ private void RebuildScattererProperties()
+ {
+ _currentScattererType = _resource.Scatterer.Value?.GetType();
+ _scattererProperties?.Clear( true );
+
+ if ( !_resource.Scatterer.HasValue )
+ return;
+
+ var so = _resource.Scatterer.Value.GetSerialized();
+ if ( so is null )
+ return;
+
+ so.OnPropertyChanged += ( prop ) => NoteChanged( prop );
+
+ var sheet = new ControlSheet();
+ sheet.AddObject( so );
+ _scattererProperties.Add( sheet );
+ }
+
+ private Widget CreateStreamingTab( SerializedObject serialized )
+ {
+ var container = new Widget( null );
+ container.Layout = Layout.Column();
+ container.VerticalSizeMode = SizeMode.CanGrow;
+
+ var sheet = new ControlSheet();
+ sheet.AddRow( serialized.GetProperty( nameof( ClutterDefinition.TileSizeEnum ) ) );
+ sheet.AddRow( serialized.GetProperty( nameof( ClutterDefinition.TileRadius ) ) );
+
+ container.Layout.Add( sheet, 1 );
+ return container;
+ }
+}
diff --git a/game/addons/tools/Code/Scene/ClutterTool/ClutterEntriesGridWidget.cs b/game/addons/tools/Code/Scene/ClutterTool/ClutterEntriesGridWidget.cs
new file mode 100644
index 00000000..a0a39fbb
--- /dev/null
+++ b/game/addons/tools/Code/Scene/ClutterTool/ClutterEntriesGridWidget.cs
@@ -0,0 +1,462 @@
+using Sandbox.Clutter;
+using Sandbox.UI;
+
+namespace Editor;
+
+///
+/// Custom control widget for editing ClutterEntry list in a grid layout.
+/// Similar to TerrainMaterialList.
+///
+[CustomEditor( typeof( List ), NamedEditor = "ClutterEntriesGrid" )]
+public class ClutterEntriesGridWidget : ControlWidget
+{
+ private SerializedProperty _listProperty;
+ private readonly ClutterEntriesListView _listView;
+
+ public override bool SupportsMultiEdit => false;
+
+ public ClutterEntriesGridWidget( SerializedProperty property ) : base( property )
+ {
+ _listProperty = property;
+
+ Layout = Layout.Column();
+ Layout.Spacing = 0;
+ VerticalSizeMode = SizeMode.CanGrow;
+
+ _listView = new ClutterEntriesListView( this, _listProperty );
+ _listView.VerticalSizeMode = SizeMode.CanGrow;
+ Layout.Add( _listView, 1 );
+
+ var buttonRow = Layout.AddRow();
+ buttonRow.Spacing = 4;
+ buttonRow.Margin = new Margin( 8, 0, 8, 8 );
+ buttonRow.AddStretchCell();
+
+ var addButton = new Button( "Add Entry", "add" );
+ addButton.Clicked = () => AddNewEntry();
+ buttonRow.Add( addButton );
+ }
+
+ private void AddNewEntry()
+ {
+ var picker = AssetPicker.Create( this, null );
+
+ picker.OnAssetPicked = ( assets ) =>
+ {
+ var entries = _listProperty.GetValue>() ?? new List();
+ foreach ( var asset in assets )
+ {
+ ClutterEntry newEntry = new();
+
+ if ( asset.AssetType.FileExtension == "vmdl" && asset.TryLoadResource( out var model ) )
+ {
+ newEntry.Model = model;
+ }
+ else if ( asset.AssetType.FileExtension == "prefab" && asset.TryLoadResource( out var prefabFile ) )
+ {
+ var prefab = SceneUtility.GetPrefabScene( prefabFile );
+ newEntry.Prefab = prefab;
+ }
+
+ if ( newEntry.HasAsset )
+ {
+ entries.Add( newEntry );
+ }
+ }
+
+ _listProperty.SetValue( entries );
+ _listProperty.Parent?.NoteChanged( _listProperty );
+ _listView.BuildItems();
+ };
+
+ picker.Show();
+ }
+
+ ///
+ /// ListView for displaying clutter entries in a grid
+ ///
+ private class ClutterEntriesListView : ListView
+ {
+ private SerializedProperty _listProperty;
+ private int _dragOverIndex = -1;
+
+ public ClutterEntriesListView( Widget parent, SerializedProperty listProperty ) : base( parent )
+ {
+ _listProperty = listProperty;
+
+ ItemSpacing = 4;
+ Margin = 8;
+ MinimumHeight = 200;
+ VerticalSizeMode = SizeMode.CanGrow;
+ ItemSize = new Vector2( 86, 86 + 32 );
+ ItemAlign = Align.FlexStart;
+ ItemContextMenu = ShowItemContext;
+ AcceptDrops = true;
+
+ BuildItems();
+ }
+
+ protected override bool OnDragItem( VirtualWidget item )
+ {
+ if ( item.Object is not ClutterEntry entry ) return false;
+
+ var entries = _listProperty.GetValue>();
+ if ( entries is null ) return false;
+
+ var index = entries.IndexOf( entry );
+ if ( index < 0 ) return false;
+
+ var drag = new Drag( this );
+ drag.Data.Object = index;
+ drag.Execute();
+ return true;
+ }
+
+ protected override DropAction OnItemDrag( ItemDragEvent e )
+ {
+ _dragOverIndex = -1;
+
+ // Handle reordering visual feedback
+ if ( e.Data.Object is int && e.Item.Object is ClutterEntry )
+ {
+ // Find target index for highlight
+ var idx = 0;
+ foreach ( var item in Items )
+ {
+ if ( item == e.Item.Object )
+ {
+ _dragOverIndex = idx;
+ break;
+ }
+ idx++;
+ }
+ Update();
+ return DropAction.Move;
+ }
+
+ // Handle external asset drops
+ if ( e.Data.Assets != null )
+ {
+ foreach ( var dragAsset in e.Data.Assets )
+ {
+ var path = dragAsset.AssetPath;
+ if ( string.IsNullOrEmpty( path ) ) continue;
+
+ if ( path.EndsWith( ".vmdl" ) || path.EndsWith( ".vmdl_c" ) || path.EndsWith( ".prefab" ) )
+ return DropAction.Copy;
+ }
+ }
+
+ return base.OnItemDrag( e );
+ }
+
+ public override void OnDragLeave()
+ {
+ base.OnDragLeave();
+ _dragOverIndex = -1;
+ Update();
+ }
+
+ public void BuildItems()
+ {
+ var entries = _listProperty.GetValue>();
+ if ( entries != null && entries.Count > 0 )
+ {
+ SetItems( entries.Cast