FloraRenderer.Collision.cs

Part of FloraRenderer, this file manages transient physics colliders for painted flora. It tracks which instances near the viewer need colliders, creates GameObject ModelCollider proxies for them, reuses and destroys colliders as the viewer or storage changes, and keeps a hidden collision root.

Native Interop
using System;
using System.Collections.Generic;
using Sandbox;

namespace RedSnail.FloraTool;

/// <summary>
/// Collision for painted flora. Instances are drawn entirely on the GPU, so nothing exists for the
/// player to walk into - colliders are spawned only for instances near the viewer and recycled as it
/// moves, keeping the physics cost tied to what is reachable rather than to the whole painted set.
/// </summary>
public sealed partial class FloraRenderer
{
	private readonly record struct CollisionKey( string ModelPath, 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> _wanted = [];
	private readonly List<CollisionKey> _stale = [];

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

	/// <summary>
	/// Rebuilding the set walks every painted 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 ( Storage is null || !Definition.IsValid() )
		{
			ReleaseCollision();
			return;
		}

		var radius = Definition.CollisionRadius;
		if ( radius <= 0.0f )
		{
			ReleaseCollision();
			return;
		}

		// Painting invalidates the set regardless of whether the viewer moved.
		var storageChanged = _collisionRevision != Storage.Revision;

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

		_collisionRevision = Storage.Revision;
		_lastCollisionOrigin = origin;
		_hasCollisionOrigin = true;

		GatherWanted( origin, radius );
		SyncColliders();
	}

	private void GatherWanted( Vector3 origin, float radius )
	{
		_wanted.Clear();

		var radiusSquared = radius * radius;

		foreach ( var (modelPath, instances) in Storage.Instances )
		{
			var entry = Definition.FindEntry( modelPath );

			// No entry means the model is painted but no longer in the palette - leave it visual
			// rather than guessing that it should be solid.
			if ( entry?.EnablePhysics is not true )
				continue;

			for ( var i = 0; i < instances.Count; i++ )
			{
				if ( instances[i].Position.DistanceSquared( origin ) > radiusSquared )
					continue;

				_wanted.Add( new CollisionKey( modelPath, i ) );
			}
		}
	}

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

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

			_stale.Add( key );
		}

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

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

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

	private GameObject CreateCollider( CollisionKey key )
	{
		var instances = Storage.GetInstances( key.ModelPath );
		if ( key.Index < 0 || key.Index >= instances.Count )
			return null;

		var entry = Definition.FindEntry( key.ModelPath );
		var model = entry?.Model ?? Model.Load( key.ModelPath );

		if ( !model.IsValid() )
			return null;

		EnsureCollisionRoot();

		var instance = instances[key.Index];

		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 only exists on the GPU.
		gameObject.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;

		var collider = gameObject.Components.Create<ModelCollider>();
		collider.Model = 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();
		_wanted.Clear();
		_stale.Clear();

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

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