Code/FloraRenderer.Collision.cs

Collision management for painted flora instances. It tracks nearby painted instances, spawns transient GameObject model colliders for those within a radius of the viewer, reuses and destroys colliders as the viewer or storage changes, and keeps a hidden collision root object.

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;
	}
}