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