From 45fd416382b872444c027fce1dcc13634fc2ecfb Mon Sep 17 00:00:00 2001 From: Antoine Pilote Date: Tue, 3 Mar 2026 17:06:44 -0800 Subject: [PATCH] Clutter system (#3989) https://files.facepunch.com/antopilo/1b0311b1/sbox-dev_Ghn3TRf8eM.mp4 https://files.facepunch.com/antopilo/1b0311b1/sbox-dev_yALD2nMaPw.mp4 --- .../Resources/Clutter/ClutterDefinition.cs | 80 +++ .../Resources/Clutter/ClutterEntry.cs | 36 ++ .../Clutter/ClutterBatchSceneObject.cs | 79 +++ .../Clutter/ClutterComponent.Infinite.cs | 55 +++ .../Clutter/ClutterComponent.Volume.cs | 259 ++++++++++ .../Components/Clutter/ClutterComponent.cs | 77 +++ .../Clutter/ClutterGenerationJob.cs | 163 ++++++ .../Scene/Components/Clutter/ClutterLayer.cs | 274 +++++++++++ .../Components/Clutter/ClutterModelBatch.cs | 39 ++ .../Components/Clutter/ClutterSettings.cs | 32 ++ .../Scene/Components/Clutter/ClutterTile.cs | 48 ++ .../Scene/Components/Clutter/Scatterer.cs | 204 ++++++++ .../Components/Clutter/SimpleScatterer.cs | 73 +++ .../Components/Clutter/TerrainScatterer.cs | 436 +++++++++++++++++ .../ClutterGridSystem.Storage.cs | 187 +++++++ .../GameObjectSystems/ClutterGridSystem.cs | 422 ++++++++++++++++ engine/Sandbox.Engine/Utility/Json/Json.cs | 1 + .../Attributes/LibraryAttribute.cs | 2 +- engine/Sandbox.System/Utility/AnyOfType.cs | 1 + .../ClutterDefinitionAssetPreview.cs | 222 +++++++++ .../ClutterTool/ClutterDefinitionEditor.cs | 117 +++++ .../ClutterTool/ClutterEntriesGridWidget.cs | 462 ++++++++++++++++++ .../Code/Scene/ClutterTool/ClutterList.cs | 158 ++++++ .../Code/Scene/ClutterTool/ClutterTool.cs | 195 ++++++++ .../Code/Scene/Tools/EditorToolManager.cs | 1 + .../ControlWidgets/AnyOfTypeControlWidget.cs | 6 +- 26 files changed, 3625 insertions(+), 4 deletions(-) create mode 100644 engine/Sandbox.Engine/Resources/Clutter/ClutterDefinition.cs create mode 100644 engine/Sandbox.Engine/Resources/Clutter/ClutterEntry.cs create mode 100644 engine/Sandbox.Engine/Scene/Components/Clutter/ClutterBatchSceneObject.cs create mode 100644 engine/Sandbox.Engine/Scene/Components/Clutter/ClutterComponent.Infinite.cs create mode 100644 engine/Sandbox.Engine/Scene/Components/Clutter/ClutterComponent.Volume.cs create mode 100644 engine/Sandbox.Engine/Scene/Components/Clutter/ClutterComponent.cs create mode 100644 engine/Sandbox.Engine/Scene/Components/Clutter/ClutterGenerationJob.cs create mode 100644 engine/Sandbox.Engine/Scene/Components/Clutter/ClutterLayer.cs create mode 100644 engine/Sandbox.Engine/Scene/Components/Clutter/ClutterModelBatch.cs create mode 100644 engine/Sandbox.Engine/Scene/Components/Clutter/ClutterSettings.cs create mode 100644 engine/Sandbox.Engine/Scene/Components/Clutter/ClutterTile.cs create mode 100644 engine/Sandbox.Engine/Scene/Components/Clutter/Scatterer.cs create mode 100644 engine/Sandbox.Engine/Scene/Components/Clutter/SimpleScatterer.cs create mode 100644 engine/Sandbox.Engine/Scene/Components/Clutter/TerrainScatterer.cs create mode 100644 engine/Sandbox.Engine/Scene/GameObjectSystems/ClutterGridSystem.Storage.cs create mode 100644 engine/Sandbox.Engine/Scene/GameObjectSystems/ClutterGridSystem.cs create mode 100644 game/addons/tools/Code/Scene/ClutterTool/ClutterDefinitionAssetPreview.cs create mode 100644 game/addons/tools/Code/Scene/ClutterTool/ClutterDefinitionEditor.cs create mode 100644 game/addons/tools/Code/Scene/ClutterTool/ClutterEntriesGridWidget.cs create mode 100644 game/addons/tools/Code/Scene/ClutterTool/ClutterList.cs create mode 100644 game/addons/tools/Code/Scene/ClutterTool/ClutterTool.cs 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() ); + } + else + { + SetItems( [] ); + } + } + + private void ShowItemContext( object obj ) + { + if ( obj is not ClutterEntry entry ) + return; + + var entries = _listProperty.GetValue>(); + if ( entries is null ) return; + + var index = entries.IndexOf( entry ); + if ( index < 0 ) return; + + var menu = new Menu( this ); + + if ( entry.Model is not null || entry.Prefab is not null ) + { + menu.AddOption( "Find in Asset Browser", "search", () => + { + Asset asset = null; + + if ( entry.Prefab is PrefabScene prefabScene && prefabScene.Source is not null ) + { + asset = AssetSystem.FindByPath( prefabScene.Source.ResourcePath ); + } + else if ( entry.Model is not null ) + { + asset = AssetSystem.FindByPath( entry.Model.ResourcePath ); + } + + if ( asset is not null ) + { + LocalAssetBrowser.OpenTo( asset, true ); + } + } ); + + menu.AddSeparator(); + } + + menu.AddOption( "Set Weight...", "balance", () => + { + var popup = new PopupWidget( this ); + popup.Layout = Layout.Row(); + popup.Layout.Margin = 8; + popup.Layout.Spacing = 8; + popup.MinimumWidth = 200; + + var slider = new FloatSlider( popup ); + slider.Minimum = 0.01f; + slider.Maximum = 1f; + slider.Value = entry.Weight; + slider.MinimumWidth = 150; + slider.OnValueEdited += () => + { + entry.Weight = slider.Value; + _listProperty.SetValue( entries ); + _listProperty.Parent?.NoteChanged( _listProperty ); + Update(); + }; + popup.Layout.Add( slider ); + + popup.OpenAtCursor(); + } ); + + menu.AddSeparator(); + + menu.AddOption( "Remove Entry", "close", () => + { + entries.RemoveAt( index ); + _listProperty.SetValue( entries ); + _listProperty.Parent?.NoteChanged( _listProperty ); + BuildItems(); + } ); + + menu.OpenAtCursor(); + } + + public override void OnDragHover( DragEvent e ) + { + base.OnDragHover( e ); + + if ( e.Data.Object is int ) + { + e.Action = DropAction.Move; + return; + } + + if ( e.Data.Assets is null ) return; + + foreach ( var dragAsset in e.Data.Assets ) + { + var path = dragAsset.AssetPath; + if ( string.IsNullOrEmpty( path ) ) continue; + + var isModel = path.EndsWith( ".vmdl" ) || path.EndsWith( ".vmdl_c" ); + var isPrefab = path.EndsWith( ".prefab" ); + + if ( isModel || isPrefab ) + { + e.Action = DropAction.Copy; + return; + } + } + } + + public override void OnDragDrop( DragEvent e ) + { + base.OnDragDrop( e ); + + _dragOverIndex = -1; + + // Handle internal reordering + if ( e.Data.Object is int oldIndex ) + { + var hoveredItem = GetItemAt( e.LocalPosition ); + if ( hoveredItem?.Object is ClutterEntry ) + { + // Find the target index + var newIndex = -1; + var idx = 0; + foreach ( var item in Items ) + { + if ( item == hoveredItem.Object ) + { + newIndex = idx; + break; + } + idx++; + } + + var entries = _listProperty.GetValue>(); + if ( entries is not null && oldIndex >= 0 && newIndex >= 0 && oldIndex != newIndex && oldIndex < entries.Count && newIndex < entries.Count ) + { + // Move entry from oldIndex to newIndex + var entry = entries[oldIndex]; + entries.RemoveAt( oldIndex ); + entries.Insert( newIndex, entry ); + + _listProperty.SetValue( entries ); + _listProperty.Parent?.NoteChanged( _listProperty ); + BuildItems(); + } + } + Update(); + return; + } + + if ( e.Data.Assets != null ) + AddAssetsFromDrop( e.Data.Assets ); + } + + private async void AddAssetsFromDrop( IEnumerable draggedAssets ) + { + var entries = _listProperty.GetValue>() ?? new List(); + + foreach ( var dragAsset in draggedAssets ) + { + var path = dragAsset.AssetPath; + if ( string.IsNullOrEmpty( path ) ) continue; + + var isModel = path.EndsWith( ".vmdl" ) || path.EndsWith( ".vmdl_c" ); + var isPrefab = path.EndsWith( ".prefab" ); + + if ( !isModel && !isPrefab ) continue; + + var asset = await dragAsset.GetAssetAsync(); + if ( asset is null ) continue; + + var newEntry = new ClutterEntry(); + + if ( isModel && asset.TryLoadResource( out var model ) ) + { + newEntry.Model = model; + } + else if ( isPrefab && 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 ); + BuildItems(); + } + + protected override void PaintItem( VirtualWidget item ) + { + if ( item.Object is not ClutterEntry entry ) + return; + + var rect = item.Rect.Shrink( 0, 0, 0, 32 ); + + // Get asset for thumbnail + Asset asset = null; + if ( entry.Prefab is PrefabScene prefabScene && prefabScene.Source is not null ) + { + asset = AssetSystem.FindByPath( prefabScene.Source.ResourcePath ); + } + else if ( entry.Model is not null ) + { + asset = AssetSystem.FindByPath( entry.Model.ResourcePath ); + } + + // Hover highlight + if ( Paint.HasMouseOver ) + { + Paint.SetBrush( Theme.Blue.WithAlpha( 0.2f ) ); + Paint.ClearPen(); + Paint.DrawRect( item.Rect, 4 ); + } + + // Drag-over highlight + var entries = _listProperty.GetValue>(); + if ( entries is not null && _dragOverIndex >= 0 && _dragOverIndex < entries.Count ) + { + if ( entries[_dragOverIndex] == entry ) + { + Paint.ClearBrush(); + Paint.SetPen( Theme.Primary, 2f ); + Paint.DrawRect( item.Rect.Shrink( 1 ), 4 ); + } + } + + Paint.SetBrush( Theme.ControlBackground ); + Paint.ClearPen(); + Paint.DrawRect( rect.Shrink( 2 ), 4 ); + + // Draw thumbnail + if ( asset is not null ) + { + var pixmap = asset.GetAssetThumb( true ); + if ( pixmap is not null ) + { + Paint.Draw( rect.Shrink( 2 ), pixmap ); + } + else + { + Paint.SetPen( Theme.Text.WithAlpha( 0.3f ) ); + Paint.DrawIcon( rect.Shrink( 24 ), "category", 32 ); + } + } + else + { + // Empty entry + Paint.SetPen( Theme.Text.WithAlpha( 0.3f ) ); + Paint.DrawIcon( rect.Shrink( 24 ), "add_photo_alternate", 32 ); + } + + // Border + Paint.ClearBrush(); + Paint.SetPen( Theme.ControlBackground.Lighten( 0.1f ) ); + Paint.DrawRect( rect.Shrink( 2 ), 4 ); + + // Weight label at bottom + var weightRect = new Rect( item.Rect.Left, rect.Bottom + 2, item.Rect.Width, 28 ); + Paint.SetDefaultFont( 9 ); + Paint.SetPen( Theme.Text.WithAlpha( 0.8f ) ); + + var weightText = $"Weight: {entry.Weight:F2}"; + if ( asset is not null ) + { + weightText = $"{asset.Name}\n{weightText}"; + } + + Paint.DrawText( weightRect, weightText, TextFlag.CenterTop ); + } + + protected override void OnPaint() + { + // Background + Paint.ClearPen(); + Paint.SetBrush( Theme.ControlBackground ); + Paint.DrawRect( LocalRect, 4 ); + + var entries = _listProperty.GetValue>(); + if ( entries is null or { Count: 0 } ) + { + Paint.SetDefaultFont( 11 ); + Paint.SetPen( Theme.Text.WithAlpha( 0.4f ) ); + Paint.DrawText( LocalRect, "Drag & drop Prefabs or Models here\nor click Add Entry", TextFlag.Center ); + } + + base.OnPaint(); + } + } +} diff --git a/game/addons/tools/Code/Scene/ClutterTool/ClutterList.cs b/game/addons/tools/Code/Scene/ClutterTool/ClutterList.cs new file mode 100644 index 00000000..d7bbd939 --- /dev/null +++ b/game/addons/tools/Code/Scene/ClutterTool/ClutterList.cs @@ -0,0 +1,158 @@ +using Sandbox; +using Sandbox.Clutter; +using System; +using System.Linq; + +namespace Editor; + +/// +/// Grid-based list view for ClutterDefinition resources, similar to TerrainMaterialList. +/// +public class ClutterList : ListView +{ + public ClutterDefinition SelectedClutter { get; private set; } + public Action OnClutterSelected { get; set; } + + public ClutterList( Widget parent ) : base( parent ) + { + ItemSelected = OnItemClicked; + ItemActivated = OnItemDoubleClicked; + ItemContextMenu = ShowItemContext; + Margin = 8; + ItemSpacing = 4; + MinimumHeight = 200; + + ItemSize = new Vector2( 86, 86 + 16 ); + ItemAlign = Sandbox.UI.Align.FlexStart; + + BuildItems(); + } + + protected void OnItemClicked( object value ) + { + if ( value is null && SelectedClutter != null ) + { + return; + } + if ( value is not ClutterDefinition clutter ) + return; + + SelectedClutter = clutter; + OnClutterSelected?.Invoke( clutter ); + } + + protected void OnItemDoubleClicked( object obj ) + { + if ( obj is not ClutterDefinition clutter ) + return; + + var asset = AssetSystem.FindByPath( clutter.ResourcePath ); + asset?.OpenInEditor(); + } + + private void ShowItemContext( object obj ) + { + if ( obj is not ClutterDefinition clutter ) + return; + + var m = new ContextMenu( this ); + m.AddOption( "Open In Editor", "edit", () => + { + var asset = AssetSystem.FindByPath( clutter.ResourcePath ); + asset?.OpenInEditor(); + } ); + + m.AddOption( "Show In Asset Browser", "folder_open", () => + { + var asset = AssetSystem.FindByPath( clutter.ResourcePath ); + if ( asset != null ) + { + MainAssetBrowser.Instance?.Local.FocusOnAsset( asset ); + } + } ); + + m.OpenAtCursor(); + } + + public void BuildItems() + { + var clutters = ResourceLibrary.GetAll().ToList(); + SetItems( clutters.Cast() ); + // Auto-select first item if nothing is selected + if ( SelectedClutter == null && clutters.Count > 0 ) + { + var firstClutter = clutters[0]; + SelectedClutter = firstClutter; + OnClutterSelected?.Invoke( firstClutter ); + } + } + + public void Refresh() + { + BuildItems(); + } + + protected override void PaintItem( VirtualWidget item ) + { + var rect = item.Rect.Shrink( 0, 0, 0, 16 ); + + if ( item.Object is not ClutterDefinition clutter ) + return; + + var asset = AssetSystem.FindByPath( clutter.ResourcePath ); + + // Selection/hover highlight + var isSelected = clutter == SelectedClutter || item.Selected; + if ( isSelected || Paint.HasMouseOver ) + { + Paint.SetBrush( Theme.Blue.WithAlpha( isSelected ? 0.5f : 0.2f ) ); + Paint.ClearPen(); + Paint.DrawRect( item.Rect, 4 ); + } + + // Thumbnail + var pixmap = asset?.GetAssetThumb(); + if ( pixmap != null ) + { + Paint.Draw( rect.Shrink( 2 ), pixmap ); + } + else + { + // Fallback: draw background and icon + Paint.SetBrush( Theme.ControlBackground ); + Paint.ClearPen(); + Paint.DrawRect( rect.Shrink( 2 ), 4 ); + + Paint.SetPen( Theme.Green ); + Paint.DrawIcon( rect.Shrink( 12 ), "forest", 32 ); + } + + // Entry count badge + var entryCount = clutter.Entries?.Count ?? 0; + if ( entryCount > 0 ) + { + var badgeRect = new Rect( rect.Right - 18, rect.Top + 4, 16, 16 ); + Paint.SetBrush( Theme.ControlBackground.WithAlpha( 0.8f ) ); + Paint.ClearPen(); + Paint.DrawRect( badgeRect, 8 ); + + Paint.SetDefaultFont( 9 ); + Paint.SetPen( Color.White ); + Paint.DrawText( badgeRect, entryCount.ToString(), TextFlag.Center ); + } + + // Name label + Paint.SetDefaultFont(); + Paint.SetPen( Theme.Text ); + Paint.DrawText( item.Rect.Shrink( 2 ), clutter.ResourceName, TextFlag.CenterBottom ); + } + + protected override void OnPaint() + { + Paint.ClearPen(); + Paint.SetBrush( Theme.ControlBackground ); + Paint.DrawRect( LocalRect, 4 ); + + base.OnPaint(); + } +} diff --git a/game/addons/tools/Code/Scene/ClutterTool/ClutterTool.cs b/game/addons/tools/Code/Scene/ClutterTool/ClutterTool.cs new file mode 100644 index 00000000..4aa1fcd2 --- /dev/null +++ b/game/addons/tools/Code/Scene/ClutterTool/ClutterTool.cs @@ -0,0 +1,195 @@ +using Editor.TerrainEditor; +using Sandbox.Clutter; +using System; + +namespace Editor; + +[EditorTool( "clutter" )] +[Title( "Clutter" )] +[Icon( "forest" )] +public sealed class ClutterTool : EditorTool +{ + private BrushPreviewSceneObject _brushPreview; + private ClutterList _clutterList; + public BrushSettings BrushSettings { get; private set; } = new(); + [Property] public ClutterDefinition SelectedClutter { get; set; } + + private bool _erasing = false; + private bool _dragging = false; + private bool _painting = false; + private Vector3 _lastPaintPosition; + private float _paintDistanceThreshold = 50f; + + public override Widget CreateToolSidebar() + { + var sidebar = new ToolSidebarWidget(); + sidebar.AddTitle( "Clutter Brush Settings", "brush" ); + sidebar.MinimumWidth = 300; + + // Brush Properties + { + var group = sidebar.AddGroup( "Brush Properties" ); + var so = BrushSettings.GetSerialized(); + group.Add( ControlSheet.CreateRow( so.GetProperty( nameof( BrushSettings.Size ) ) ) ); + group.Add( ControlSheet.CreateRow( so.GetProperty( nameof( BrushSettings.Opacity ) ) ) ); + } + + // Clutter Selection + { + var group = sidebar.AddGroup( "Clutter Definitions", SizeMode.Flexible ); + _clutterList = new ClutterList( sidebar ); + _clutterList.MinimumHeight = 300; + _clutterList.OnClutterSelected = ( clutter ) => + { + SelectedClutter = clutter; + }; + + group.Add( _clutterList ); + } + + // Clear All + { + var group = sidebar.AddGroup( "Actions" ); + var clearBtn = new Button( "Clear All", "delete_sweep" ); + clearBtn.Clicked += () => + { + var system = Scene.GetSystem(); + system?.ClearAllPainted(); + }; + clearBtn.ToolTip = "Remove all painted clutter"; + group.Add( clearBtn ); + } + + return sidebar; + } + + public override void OnUpdate() + { + var ctlrHeld = Gizmo.IsCtrlPressed; + if ( Gizmo.IsCtrlPressed && !_erasing ) + { + _erasing = true; + } + + DrawBrushPreview(); + + Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 999999 ) ); + + if ( Gizmo.IsLeftMouseDown ) + { + if ( !_dragging ) + { + _dragging = true; + OnPaintBegin(); + } + + OnPaintUpdate(); + } + else if ( _dragging ) + { + _dragging = false; + OnPaintEnded(); + } + + if ( !ctlrHeld ) + { + _erasing = false; + } + } + + public override void OnDisabled() + { + _brushPreview?.Delete(); + } + + private void OnPaintBegin() + { + _lastPaintPosition = Vector3.Zero; + } + + private void OnPaintUpdate() + { + if ( SelectedClutter?.Scatterer == null ) return; + + var tr = Scene.Trace.Ray( Gizmo.CurrentRay, 100000 ) + .UseRenderMeshes( true ) + .WithTag( "solid" ) + .WithoutTags( "clutter" ) + .Run(); + + if ( !tr.Hit ) return; + if ( _lastPaintPosition != Vector3.Zero && + Vector3.DistanceBetween( tr.HitPosition, _lastPaintPosition ) < _paintDistanceThreshold ) + return; + + _lastPaintPosition = tr.HitPosition; + + var system = Scene.GetSystem(); + var brushRadius = (float)BrushSettings.Size; + var bounds = BBox.FromPositionAndSize( tr.HitPosition, brushRadius * 2f ); + + if ( _erasing ) + { + system.Erase( tr.HitPosition, brushRadius ); + } + else + { + var instances = SelectedClutter.Scatterer.Value.Scatter( bounds, SelectedClutter, Random.Shared.Next(), Scene ); + var count = (int)(instances.Count * BrushSettings.Opacity); + + foreach ( var instance in instances.Take( count ) ) + { + // Paint both models and prefabs + if ( instance.Entry != null && instance.Entry.HasAsset ) + { + var t = instance.Transform; + system.Paint( instance.Entry, t.Position, t.Rotation, t.Scale.x ); + } + } + } + + _painting = true; + } + + private void OnPaintEnded() + { + _lastPaintPosition = Vector3.Zero; + + if ( _painting ) + { + var system = Scene.GetSystem(); + system.Flush(); + } + + _painting = false; + } + + private void DrawBrushPreview() + { + var tr = Scene.Trace.Ray( Gizmo.CurrentRay, 50000 ) + .UseRenderMeshes( true ) + .WithTag( "solid" ) + .WithoutTags( "clutter" ) + .Run(); + + if ( !tr.Hit ) + return; + + _brushPreview ??= new BrushPreviewSceneObject( Gizmo.World ); + + var brushRadius = BrushSettings.Size; + var color = _erasing ? Color.FromBytes( 250, 150, 150 ) : Color.FromBytes( 150, 150, 250 ); + color.a = BrushSettings.Opacity; + + var brush = TerrainEditorTool.Brush; + var previewPosition = tr.HitPosition + tr.Normal * 1f; + var surfaceRotation = Rotation.LookAt( tr.Normal ); + + _brushPreview.RenderLayer = SceneRenderLayer.OverlayWithDepth; + _brushPreview.Bounds = BBox.FromPositionAndSize( 0, float.MaxValue ); + _brushPreview.Transform = new Transform( previewPosition, surfaceRotation ); + _brushPreview.Radius = brushRadius; + _brushPreview.Texture = brush?.Texture; + _brushPreview.Color = color; + } +} diff --git a/game/addons/tools/Code/Scene/Tools/EditorToolManager.cs b/game/addons/tools/Code/Scene/Tools/EditorToolManager.cs index 7fde0dfb..e204830a 100644 --- a/game/addons/tools/Code/Scene/Tools/EditorToolManager.cs +++ b/game/addons/tools/Code/Scene/Tools/EditorToolManager.cs @@ -200,6 +200,7 @@ public class EditorToolManager public void DisposeAll() { previousHash = -1; + CurrentTool?.Dispose(); foreach ( var tool in ComponentTools ) tool?.Dispose(); ComponentTools.Clear(); diff --git a/game/addons/tools/Code/Widgets/ControlWidgets/AnyOfTypeControlWidget.cs b/game/addons/tools/Code/Widgets/ControlWidgets/AnyOfTypeControlWidget.cs index 72c8c9e7..4b255e70 100644 --- a/game/addons/tools/Code/Widgets/ControlWidgets/AnyOfTypeControlWidget.cs +++ b/game/addons/tools/Code/Widgets/ControlWidgets/AnyOfTypeControlWidget.cs @@ -16,7 +16,7 @@ sealed class AnyOfTypeControlWidget : DropdownControlWidget public AnyOfTypeControlWidget( SerializedProperty property ) : base( property ) { _baseType = property.PropertyType.GenericTypeArguments[0]; - _wrapperType = EditorTypeLibrary.GetType( typeof( AnyOfType<> ).MakeGenericType( _baseType ) ); + _wrapperType = EditorTypeLibrary.GetType( typeof( AnyOfType<> ) ); Layout = Layout.Column(); Layout.AddSpacingCell( Theme.RowHeight ); @@ -59,7 +59,7 @@ sealed class AnyOfTypeControlWidget : DropdownControlWidget if ( typeDesc is null ) { - SerializedProperty.SetValue( _wrapperType.Create() ); + SerializedProperty.SetValue( _wrapperType.CreateGeneric( [_baseType] ) ); return; } @@ -78,7 +78,7 @@ sealed class AnyOfTypeControlWidget : DropdownControlWidget void WriteWrapper( object instance ) { - SerializedProperty.SetValue( _wrapperType.Create( [instance] ) ); + SerializedProperty.SetValue( _wrapperType.CreateGeneric( [_baseType], [instance] ) ); } void RebuildPropertySheet( object instance )