Code/FloraRenderer.cs

A component that renders painted flora as engine SceneObjects rather than serialized entities. It creates a SceneObject per painted instance from stored FloraStorage, keeps them in sync when storage changes, updates editor gizmos, and maintains nearby collision following the viewer.

Native Interop
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 );
		}
	}
}