Code/Spawners/ApexClutterComponent.cs
using Sandbox.Rendering;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace ApexWorld;
public sealed class ApexClutterComponent : Component, Component.ExecuteInEditor
{
#region Properties
[Property] public Model Model { get; set; }
[Property] public Color Tint { get; set; } = Color.White;
[Property, Range( 1, 512 )] public int GridSize { get; set; } = 128;
[Property, Range( 10f, 500f )] public float Spacing { get; set; } = 100f;
[Property, Range( 0f, 100f )] public float Jitter { get; set; } = 25f;
[Property] public Vector2 ScaleRange { get; set; } = new( 0.8f, 1.2f );
[Property, Range( 256f, 8192f )] public float ChunkWorldSize { get; set; } = 1024f;
[Property] public float BoundsPadding { get; set; } = 256f;
[Property] public float VerticalBoundsPadding { get; set; } = 5000f;
private static RenderAttributes _attributes = new();
[Property]
private List<Transform> SerializedTransforms { get; set; } = new();
#endregion
#region Chunk Scene Object
/// <summary>
/// Proper SceneCustomObject subclass that renders instanced models directly.
/// Stores transforms and tint, draws on render thread with Graphics.DrawModelInstanced.
/// </summary>
private sealed class ChunkSceneObject : SceneCustomObject
{
private Model _model;
private List<Transform> _transforms = new();
public ChunkSceneObject( SceneWorld world ) : base( world )
{
Flags.IsOpaque = true;
Flags.IsTranslucent = false;
Flags.CastShadows = true;
Flags.WantsPrePass = true;
}
/// <summary>
/// Stores the model, transforms, and tint for rendering.
/// Call this after creating or whenever any parameter changes.
/// </summary>
public void Build( Model model, List<Transform> transforms, Color tint )
{
_model = model;
_transforms = transforms;
}
public override void RenderSceneObject()
{
if ( _model == null || _transforms.Count == 0 ) return;
Graphics.DrawModelInstanced( _model, CollectionsMarshal.AsSpan( _transforms ), _attributes );
}
public new void Delete()
{
_model = null;
_transforms.Clear();
base.Delete();
}
}
#endregion
#region State
private sealed class ChunkData
{
public readonly List<Transform> Transforms = new();
public BBox Bounds;
public ChunkSceneObject SceneObject;
}
private readonly Dictionary<Vector2Int, ChunkData> _chunks = new();
#endregion
private void RebuildFromSerializedData()
{
foreach ( var transform in SerializedTransforms )
{
var coord = WorldToChunk( transform.Position );
if ( !_chunks.TryGetValue( coord, out var chunk ) )
{
chunk = new ChunkData
{
Bounds = GetChunkBounds( coord )
};
_chunks[coord] = chunk;
}
chunk.Transforms.Add( transform );
}
RebuildSceneObjects();
}
#region Lifecycle
protected override void OnStart()
{
base.OnStart();
if ( _chunks.Count == 0 && SerializedTransforms.Count > 0 )
{
RebuildFromSerializedData();
}
}
protected override void OnUpdate()
{
if ( Scene?.SceneWorld == null )
return;
bool rebuild = false;
foreach ( var chunk in _chunks.Values )
{
if ( chunk.SceneObject == null || !chunk.SceneObject.IsValid() )
{
rebuild = true;
break;
}
}
if ( rebuild )
{
RebuildSceneObjects();
}
}
protected override void OnDestroy()
{
base.OnDestroy();
Clear();
}
#endregion
#region Public API
public void BuildFromTransforms( List<Transform> transforms )
{
Clear();
SerializedTransforms.Clear();
SerializedTransforms.AddRange( transforms );
if ( Model == null )
{
Log.Warning( "[ApexClutter] No model assigned" );
return;
}
foreach ( var transform in transforms )
{
var coord = WorldToChunk( transform.Position );
if ( !_chunks.TryGetValue( coord, out var chunk ) )
{
chunk = new ChunkData { Bounds = GetChunkBounds( coord ) };
_chunks[coord] = chunk;
}
chunk.Transforms.Add( transform );
}
RebuildSceneObjects(); // ← this was missing
}
[Button]
public void Spawn()
{
Clear();
if ( Model == null ) { Log.Warning( "[ClutterTest] No model assigned." ); return; }
float half = GridSize * 0.5f;
for ( int y = 0; y < GridSize; y++ )
{
for ( int x = 0; x < GridSize; x++ )
{
float px = (x - half) * Spacing + Game.Random.Float( -Jitter, Jitter );
float py = (y - half) * Spacing + Game.Random.Float( -Jitter, Jitter );
var pos = WorldPosition + new Vector3( px, py, 0f );
var coord = WorldToChunk( pos );
if ( !_chunks.TryGetValue( coord, out var chunk ) )
{
chunk = new ChunkData { Bounds = GetChunkBounds( coord ) };
_chunks[coord] = chunk;
}
float scale = Game.Random.Float( ScaleRange.x, ScaleRange.y );
chunk.Transforms.Add( new Transform(
pos,
Rotation.FromYaw( Game.Random.Float( 0f, 360f ) ),
scale
) );
}
}
RebuildSceneObjects();
Log.Info( $"[ClutterTest] {_chunks.Count} chunks | {GridSize * GridSize} instances" );
}
[Button]
public void Clear()
{
foreach ( var chunk in _chunks.Values )
chunk.SceneObject?.Delete();
_chunks.Clear();
}
#endregion
#region Internal
/// <summary>
/// Creates or recreates SceneCustomObjects from existing ChunkData.
/// Called after Spawn and whenever SceneWorld changes.
/// Transforms are NOT regenerated — only render objects are rebuilt.
/// </summary>
private void RebuildSceneObjects()
{
if ( Scene?.SceneWorld == null ) return;
foreach ( var chunk in _chunks.Values )
{
chunk.SceneObject?.Delete();
chunk.SceneObject = null;
if ( chunk.Transforms.Count == 0 ) continue;
var obj = new ChunkSceneObject( Scene.SceneWorld );
obj.Bounds = chunk.Bounds;
obj.Build( Model, chunk.Transforms, Tint );
chunk.SceneObject = obj;
}
}
private BBox GetChunkBounds( Vector2Int coord )
{
return new BBox(
new Vector3(
coord.x * ChunkWorldSize - BoundsPadding,
coord.y * ChunkWorldSize - BoundsPadding,
-VerticalBoundsPadding
),
new Vector3(
(coord.x + 1) * ChunkWorldSize + BoundsPadding,
(coord.y + 1) * ChunkWorldSize + BoundsPadding,
VerticalBoundsPadding
)
);
}
private Vector2Int WorldToChunk( Vector3 pos ) => new(
(int)MathF.Floor( pos.x / ChunkWorldSize ),
(int)MathF.Floor( pos.y / ChunkWorldSize )
);
#endregion
}