FloraRenderer.Collision.cs
using System;
using System.Collections.Generic;
using Sandbox;

namespace RedSnail.FloraTool;

/// <summary>
/// Collision for painted flora. Instances only exist as scene objects, so nothing is solid until a
/// collider is made for it - and those are made only for instances near the viewer and recycled as
/// it moves, keeping physics cost tied to what is reachable rather than to the whole forest.
/// </summary>
public sealed partial class FloraRenderer
{
	private readonly record struct CollisionKey( FloraStorage.ChunkCoord Chunk, int Index );

	private readonly Dictionary<CollisionKey, GameObject> _colliders = [];

	// A set rather than a list: SyncColliders tests every live collider against it, so a linear scan
	// there would be quadratic once a few hundred are in range.
	private readonly HashSet<CollisionKey> _wantedColliders = [];
	private readonly List<CollisionKey> _staleColliders = [];

	private GameObject _collisionRoot;
	private Vector3 _lastCollisionOrigin;
	private bool _hasCollisionOrigin;
	private int _collisionRevision = -1;

	/// <summary>
	/// Rebuilding walks every streamed instance, so it only happens once the viewer has moved far
	/// enough for the answer to have changed.
	/// </summary>
	private const float CollisionRefreshDistance = 256.0f;

	private void UpdateCollision( Vector3 origin )
	{
		if ( !Definition.IsValid() || Definition.CollisionRadius <= 0.0f )
		{
			ReleaseCollision();
			return;
		}

		var storageChanged = Storage is null || _collisionRevision != Storage.Revision;

		if ( !storageChanged && _hasCollisionOrigin &&
			 origin.Distance( _lastCollisionOrigin ) < CollisionRefreshDistance )
			return;

		_collisionRevision = Storage?.Revision ?? -1;
		_lastCollisionOrigin = origin;
		_hasCollisionOrigin = true;

		GatherWantedColliders( origin );
		SyncColliders();
	}

	/// <summary>
	/// Only streamed chunks are considered. Collision radius should sit well inside the stream radius
	/// anyway, so anything outside it has no business being solid.
	/// </summary>
	private void GatherWantedColliders( Vector3 origin )
	{
		_wantedColliders.Clear();

		var radiusSquared = Definition.CollisionRadius * Definition.CollisionRadius;

		foreach ( var (coord, chunk) in _live )
		{
			for ( var i = 0; i < chunk.Instances.Count; i++ )
			{
				var instance = chunk.Instances[i];

				if ( instance.Position.DistanceSquared( origin ) > radiusSquared )
					continue;

				var entry = Definition.GetEntry( instance.EntryIndex );
				if ( entry?.EnablePhysics is not true )
					continue;

				_wantedColliders.Add( new CollisionKey( coord, i ) );
			}
		}
	}

	private void SyncColliders()
	{
		// Drop what fell out of range first, so those objects are free to be reused this same frame.
		_staleColliders.Clear();

		foreach ( var (key, gameObject) in _colliders )
		{
			if ( gameObject.IsValid() && _wantedColliders.Contains( key ) )
				continue;

			_staleColliders.Add( key );
		}

		foreach ( var key in _staleColliders )
		{
			if ( _colliders.Remove( key, out var gameObject ) && gameObject.IsValid() )
				gameObject.Destroy();
		}

		foreach ( var key in _wantedColliders )
		{
			if ( _colliders.ContainsKey( key ) )
				continue;

			var gameObject = CreateCollider( key );
			if ( gameObject.IsValid() )
				_colliders[key] = gameObject;
		}
	}

	private GameObject CreateCollider( CollisionKey key )
	{
		if ( !_live.TryGetValue( key.Chunk, out var chunk ) )
			return null;

		if ( key.Index < 0 || key.Index >= chunk.Instances.Count )
			return null;

		var instance = chunk.Instances[key.Index];

		var entry = Definition.GetEntry( instance.EntryIndex );
		if ( entry is null )
			return null;

		EnsureCollisionRoot();

		var gameObject = new GameObject( true, "FloraCollider" )
		{
			Parent = _collisionRoot,
			WorldTransform = instance.ToTransform(),
		};

		// Not saved with the scene and not shown in the hierarchy - these are transient physics
		// proxies for geometry that is regenerated from the seed anyway.
		gameObject.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;

		var collider = gameObject.Components.Create<ModelCollider>();
		collider.Model = entry.Model;
		collider.Static = true;

		return gameObject;
	}

	private void EnsureCollisionRoot()
	{
		if ( _collisionRoot.IsValid() )
			return;

		_collisionRoot = new GameObject( true, "Flora Colliders" ) { Parent = GameObject };
		_collisionRoot.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;
	}

	private void ReleaseCollision()
	{
		foreach ( var (_, gameObject) in _colliders )
		{
			if ( gameObject.IsValid() )
				gameObject.Destroy();
		}

		_colliders.Clear();
		_wantedColliders.Clear();
		_staleColliders.Clear();

		if ( _collisionRoot.IsValid() )
			_collisionRoot.Destroy();

		_collisionRoot = null;
		_hasCollisionOrigin = false;
		_collisionRevision = -1;
	}
}