FloraRenderer.cs

A component that renders painted flora as engine SceneObjects one-per-instance rather than serialized entities. It creates SceneObjects from stored instances, updates them when the Storage revision changes, draws simple editor gizmos, and updates collision around the viewer.

File Access
using System;
using System.Collections.Generic;
using Sandbox;

namespace RedSnail.FloraTool;

/// <summary>
/// Draws painted flora as scene objects - one per instance, but not GameObjects, so a forest still
/// costs a couple of megabytes in the scene file instead of thousands of serialized entities.
///
/// Scene objects rather than a hand-rolled instanced draw because they take part in every pass the
/// engine runs: the depth prepass (without which screen-space effects sample the geometry behind the
/// trunk instead of the trunk), the shadow cascades, and per-object LOD selection using the model's
/// own compiled switch distances. Standard instancing still batches them into few draw calls. Doing
/// the culling by hand meant reimplementing all of that, and the indirect path that would have made
/// it worthwhile needs Model.GetLodDrawCallRange, which is engine-internal.
/// </summary>
[Icon( "park" ), Group( "Flora" ), Title( "Flora Renderer" )]
public sealed partial class FloraRenderer : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer
{
	[Property, Group( "General" )]
	public FloraDefinition Definition { get; set; }

	/// <summary>Painted instances. Serialized as a binary blob, not JSON.</summary>
	[Property, Hide]
	public FloraStorage Storage { get; set; } = new();

	private readonly List<SceneObject> _sceneObjects = [];
	private int _builtRevision = -1;

	protected override void OnEnabled()
	{
		Storage ??= new FloraStorage();

		// The scene objects were deleted on disable, so a matching revision would leave us thinking
		// the world is already built when nothing is in it.
		_builtRevision = -1;

		RebuildSceneObjects();
	}

	protected override void OnDisabled()
	{
		ReleaseSceneObjects();
		ReleaseCollision();

		_builtRevision = -1;
	}

	protected override void OnUpdate()
	{
		// Rendering needs no per-frame work at all now - the engine culls, picks LOD and batches.
		// Only the painted set changing, or collision following the viewer, needs anything.
		RebuildSceneObjects();

		var viewer = GetViewerPosition();
		if ( viewer.HasValue )
			UpdateCollision( viewer.Value );
	}

	/// <summary>
	/// Where collision should be gathered around. While editing that is the viewport camera, so
	/// colliders follow what you are looking at rather than wherever the game camera is parked.
	/// </summary>
	private Vector3? GetViewerPosition()
	{
		if ( Scene.IsEditor )
		{
			var editorCamera = Application.Editor?.Camera;
			if ( editorCamera.IsValid() )
				return editorCamera.WorldPosition;
		}

		return Scene.Camera.IsValid() ? Scene.Camera.WorldPosition : null;
	}

	/// <summary>
	/// Recreates a scene object per painted instance. Only runs when the painted set actually
	/// changed - a static forest costs nothing per frame.
	/// </summary>
	private void RebuildSceneObjects()
	{
		if ( Storage is null )
			return;

		if ( _builtRevision == Storage.Revision )
			return;

		ReleaseSceneObjects();
		_builtRevision = Storage.Revision;

		var world = Scene.SceneWorld;
		if ( !world.IsValid() )
		{
			// No world yet - try again next frame rather than recording this revision as built.
			_builtRevision = -1;
			return;
		}

		foreach ( var (modelPath, instances) in Storage.Instances )
		{
			if ( instances.Count == 0 )
				continue;

			var entry = Definition.IsValid() ? Definition.FindEntry( modelPath ) : null;
			var model = entry?.Model ?? Model.Load( modelPath );

			if ( !model.IsValid() )
				continue;

			// Entries dropped from the palette keep rendering with sensible defaults rather than
			// vanishing, so removing one from the definition doesn't silently delete painted work.
			var castShadows = entry?.CastShadows ?? true;

			for ( var i = 0; i < instances.Count; i++ )
			{
				var sceneObject = new SceneObject( world, model, instances[i].ToTransform() );
				sceneObject.Flags.CastShadows = castShadows;

				_sceneObjects.Add( sceneObject );
			}
		}
	}

	private void ReleaseSceneObjects()
	{
		foreach ( var sceneObject in _sceneObjects )
		{
			if ( sceneObject.IsValid() )
				sceneObject.Delete();
		}

		_sceneObjects.Clear();
	}

	/// <summary>
	/// Called by the editor tool after painting, so the next frame rebuilds the scene objects.
	/// </summary>
	public void MarkDirty() => _builtRevision = -1;

	protected override void DrawGizmos()
	{
		if ( !Gizmo.IsSelected || Storage is null || Storage.TotalCount == 0 )
			return;

		Gizmo.Draw.Color = Color.Green.WithAlpha( 0.4f );

		foreach ( var (_, instances) in Storage.Instances )
		{
			for ( var i = 0; i < instances.Count; i++ )
				Gizmo.Draw.LineSphere( WorldTransform.PointToLocal( instances[i].Position ), 24.0f, 6 );
		}
	}
}