A data container for painted flora instances grouped by model path. Stores Instance records (position, rotation, scale), provides add/remove/erase/clear operations, serialization to a binary writer and deserialization from a reader, and maintains a Revision counter and simple queries like IsClear and GetInstances.
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.FloraTool;
/// <summary>
/// Painted flora instances, grouped by the model they draw. Unlike grass, flora is placed rather
/// than generated - each instance is a deliberate transform an artist put somewhere - so the
/// transforms are stored rather than derived from a density field. Serialized as a binary blob, so
/// a few tens of thousands of trees cost a couple of megabytes instead of that many GameObjects.
/// </summary>
public sealed class FloraStorage : BlobData
{
public override int Version => 1;
public record struct Instance( Vector3 Position, Rotation Rotation, float Scale )
{
public readonly Transform ToTransform() => new( Position, Rotation, Scale );
}
private readonly Dictionary<string, List<Instance>> _instances = [];
/// <summary>Bumped on every mutation so the renderer knows to rebuild its GPU buffers.</summary>
public int Revision { get; private set; }
public IReadOnlyDictionary<string, List<Instance>> Instances => _instances;
public int ModelCount => _instances.Count;
public int TotalCount
{
get
{
var count = 0;
foreach ( var list in _instances.Values )
count += list.Count;
return count;
}
}
public IReadOnlyList<Instance> GetInstances( string modelPath ) =>
_instances.TryGetValue( modelPath, out var list ) ? list : [];
public void AddInstance( string modelPath, Vector3 position, Rotation rotation, float scale )
{
if ( string.IsNullOrEmpty( modelPath ) )
return;
if ( !_instances.TryGetValue( modelPath, out var list ) )
{
list = [];
_instances[modelPath] = list;
}
list.Add( new Instance( position, rotation, scale ) );
Revision++;
}
/// <summary>
/// True when nothing is planted within <paramref name="spacing"/> of the position. Painting uses
/// this to keep instances off each other rather than piling several trees into one spot.
/// </summary>
public bool IsClear( Vector3 position, float spacing )
{
var spacingSquared = spacing * spacing;
foreach ( var list in _instances.Values )
{
for ( var i = 0; i < list.Count; i++ )
{
if ( list[i].Position.DistanceSquared( position ) < spacingSquared )
return false;
}
}
return true;
}
/// <summary>
/// Removes every instance within a radius, ignoring height so the brush erases what is under the
/// cursor rather than only what sits at the traced elevation.
/// </summary>
public int Erase( Vector3 center, float radius )
{
var radiusSquared = radius * radius;
var removed = 0;
foreach ( var list in _instances.Values )
{
removed += list.RemoveAll( instance =>
{
var dx = instance.Position.x - center.x;
var dy = instance.Position.y - center.y;
return dx * dx + dy * dy <= radiusSquared;
} );
}
if ( removed > 0 )
{
PruneEmptyModels();
Revision++;
}
return removed;
}
public void ClearAll()
{
if ( _instances.Count == 0 )
return;
_instances.Clear();
Revision++;
}
/// <summary>
/// Drops instances whose model no longer resolves, so a deleted asset doesn't keep a dead batch
/// alive in the renderer.
/// </summary>
public int RemoveMissingModels( Func<string, bool> exists )
{
List<string> missing = null;
foreach ( var modelPath in _instances.Keys )
{
if ( exists( modelPath ) ) continue;
missing ??= [];
missing.Add( modelPath );
}
if ( missing is null )
return 0;
foreach ( var modelPath in missing )
_instances.Remove( modelPath );
Revision++;
return missing.Count;
}
private void PruneEmptyModels()
{
List<string> empty = null;
foreach ( var (modelPath, list) in _instances )
{
if ( list.Count > 0 ) continue;
empty ??= [];
empty.Add( modelPath );
}
if ( empty is null )
return;
foreach ( var modelPath in empty )
_instances.Remove( modelPath );
}
public override void Serialize( ref Writer writer )
{
writer.Stream.Write( _instances.Count );
foreach ( var (modelPath, list) in _instances )
{
writer.Stream.Write( modelPath );
writer.Stream.Write( list.Count );
for ( var i = 0; i < list.Count; i++ )
{
writer.Stream.Write( list[i].Position );
writer.Stream.Write( list[i].Rotation );
writer.Stream.Write( list[i].Scale );
}
}
}
public override void Deserialize( ref Reader reader )
{
_instances.Clear();
var modelCount = reader.Stream.Read<int>();
for ( var m = 0; m < modelCount; m++ )
{
var modelPath = reader.Stream.Read<string>();
var instanceCount = reader.Stream.Read<int>();
var list = new List<Instance>( instanceCount );
for ( var i = 0; i < instanceCount; i++ )
{
var position = reader.Stream.Read<Vector3>();
var rotation = reader.Stream.Read<Rotation>();
var scale = reader.Stream.Read<float>();
list.Add( new Instance( position, rotation, scale ) );
}
if ( !string.IsNullOrEmpty( modelPath ) && list.Count > 0 )
_instances[modelPath] = list;
}
Revision++;
}
}