🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=redsnail.floratool&take=20
Showing code results for query:
*
(12 total matches found)
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.FloraTool;
/// <summary>
/// Renders painted flora. Coverage is stored per chunk and instances are regenerated from it plus
/// <see cref="Seed"/>, so the scene file holds a density map rather than a transform per tree - the
/// difference between a few megabytes and something a repository will refuse.
///
/// Chunks become scene objects only within the definition's stream radius. Scene objects rather than
/// a hand-rolled instanced draw because they take part in every pass the engine runs: the depth
/// prepass, the shadow cascades, and per-object LOD using the model's own compiled distances.
/// Standard instancing still batches them into few draw calls.
/// </summary>
[Icon( "park" ), Group( "Flora" ), Title( "Flora Renderer" )]
public sealed partial class FloraRenderer : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer
{
/// <summary>A chunk's generated instances and the scene objects currently standing for them.</summary>
private sealed class LiveChunk
{
public List<FloraGenerator.Instance> Instances = [];
public List<SceneObject> SceneObjects = [];
/// <summary>
/// Whether this chunk is currently allowed to cast. Tracked so the flags are only touched
/// when a chunk crosses the shadow boundary, rather than every object every frame.
/// </summary>
public bool ShadowsEnabled = true;
}
[Property, Group( "General" )]
public FloraDefinition Definition { get; set; }
/// <summary>
/// Decides exactly where each instance lands within the painted coverage. Change it to reshuffle
/// a whole forest without repainting; keep it fixed and the same trees stand in the same places
/// every run, on every machine.
/// </summary>
[Property, Group( "General" )]
public int Seed
{
get => field;
set
{
if ( field == value ) return;
field = value;
MarkDirty();
}
}
/// <summary>Painted coverage. Serialized as a binary blob, not JSON.</summary>
[Property, Hide]
public FloraStorage Storage { get; set; } = new();
private readonly Dictionary<FloraStorage.ChunkCoord, LiveChunk> _live = [];
private readonly List<FloraStorage.ChunkCoord> _wantedChunks = [];
private readonly List<FloraStorage.ChunkCoord> _staleChunks = [];
// Reused across chunk builds so streaming doesn't allocate a fresh list per chunk.
private readonly List<FloraGenerator.Instance> _scratchInstances = [];
private int _builtRevision = -1;
private Vector3 _lastStreamOrigin;
private bool _hasStreamOrigin;
/// <summary>
/// Restreaming walks every painted chunk, so it only happens once the viewer has moved far enough
/// for the answer to have changed. A fraction of a chunk keeps the boundary from thrashing.
/// </summary>
private const float StreamRefreshDistance = FloraStorage.ChunkSize * 0.25f;
protected override void OnEnabled()
{
Storage ??= new FloraStorage();
// 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;
_hasStreamOrigin = false;
}
protected override void OnDisabled()
{
ReleaseAllChunks();
ReleaseCollision();
_builtRevision = -1;
_hasStreamOrigin = false;
}
protected override void OnUpdate()
{
var viewer = GetViewerPosition();
if ( !viewer.HasValue )
return;
UpdateStreaming( viewer.Value );
UpdateCollision( viewer.Value );
}
/// <summary>
/// What streaming follows. While editing that is the viewport camera, so flora appears around
/// 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;
}
private void UpdateStreaming( Vector3 origin )
{
if ( Storage is null || !Definition.IsValid() )
{
ReleaseAllChunks();
return;
}
// Painting or reseeding invalidates everything regardless of whether the viewer moved.
var dirty = _builtRevision != Storage.Revision;
if ( !dirty && _hasStreamOrigin && origin.Distance( _lastStreamOrigin ) < StreamRefreshDistance )
return;
if ( dirty )
{
ReleaseAllChunks();
_builtRevision = Storage.Revision;
}
_lastStreamOrigin = origin;
_hasStreamOrigin = true;
GatherWantedChunks( origin );
SyncChunks( origin );
}
private void GatherWantedChunks( Vector3 origin )
{
_wantedChunks.Clear();
// A chunk's near corner can be in range while its centre is not, hence the circumradius.
var radius = Definition.StreamRadius + FloraStorage.ChunkSize * 0.7072f;
var radiusSquared = radius * radius;
foreach ( var (coord, _) in Storage.Chunks )
{
var center = FloraStorage.ChunkCenter( coord );
var dx = center.x - origin.x;
var dy = center.y - origin.y;
if ( dx * dx + dy * dy > radiusSquared )
continue;
_wantedChunks.Add( coord );
}
}
private void SyncChunks( Vector3 origin )
{
_staleChunks.Clear();
foreach ( var (coord, _) in _live )
{
if ( !_wantedChunks.Contains( coord ) )
_staleChunks.Add( coord );
}
foreach ( var coord in _staleChunks )
ReleaseChunk( coord );
foreach ( var coord in _wantedChunks )
{
if ( _live.ContainsKey( coord ) )
continue;
BuildChunk( coord, origin );
}
UpdateChunkShadows( origin );
}
/// <summary>
/// Turns shadow casting off for chunks past the shadow distance. Evaluated per chunk rather than
/// per instance, and only written when a chunk actually crosses the boundary, so a stationary
/// camera costs nothing here.
/// </summary>
private void UpdateChunkShadows( Vector3 origin )
{
foreach ( var (coord, chunk) in _live )
{
var wanted = ChunkCastsShadows( coord, origin );
if ( wanted == chunk.ShadowsEnabled )
continue;
chunk.ShadowsEnabled = wanted;
ApplyChunkShadows( chunk );
}
}
private bool ChunkCastsShadows( FloraStorage.ChunkCoord coord, Vector3 origin )
{
var distance = Definition.ShadowDistance;
if ( distance <= 0.0f )
return true;
// Measured to the chunk's near edge via its circumradius, so a chunk is only cut off once all
// of it is beyond the limit.
var limit = distance + FloraStorage.ChunkSize * 0.7072f;
var center = FloraStorage.ChunkCenter( coord );
var dx = center.x - origin.x;
var dy = center.y - origin.y;
return dx * dx + dy * dy <= limit * limit;
}
/// <summary>
/// The per-entry CastShadows setting is the ceiling - distance can only ever take shadows away,
/// never grant them to an entry the artist turned them off for.
/// </summary>
private void ApplyChunkShadows( LiveChunk chunk )
{
for ( var i = 0; i < chunk.SceneObjects.Count && i < chunk.Instances.Count; i++ )
{
var sceneObject = chunk.SceneObjects[i];
if ( !sceneObject.IsValid() )
continue;
var entry = Definition.GetEntry( chunk.Instances[i].EntryIndex );
sceneObject.Flags.CastShadows = chunk.ShadowsEnabled && entry?.CastShadows is true;
}
}
private void BuildChunk( FloraStorage.ChunkCoord coord, Vector3 origin )
{
if ( !Storage.Chunks.TryGetValue( coord, out var cells ) )
return;
var world = Scene.SceneWorld;
if ( !world.IsValid() )
return;
var chunk = new LiveChunk();
chunk.ShadowsEnabled = ChunkCastsShadows( coord, origin );
_scratchInstances.Clear();
FloraGenerator.GenerateChunk( coord, cells, Definition, Seed, _scratchInstances );
// Instances and scene objects are kept strictly parallel - anything whose entry no longer
// resolves is dropped from both. Skipping only the scene object would slide the two lists out
// of step, and the shadow and collision paths index one by the other.
for ( var i = 0; i < _scratchInstances.Count; i++ )
{
var instance = _scratchInstances[i];
var entry = Definition.GetEntry( instance.EntryIndex );
if ( entry is null )
continue;
var sceneObject = new SceneObject( world, entry.Model, instance.ToTransform() );
sceneObject.Flags.CastShadows = chunk.ShadowsEnabled && entry.CastShadows;
chunk.Instances.Add( instance );
chunk.SceneObjects.Add( sceneObject );
}
_live[coord] = chunk;
}
private void ReleaseChunk( FloraStorage.ChunkCoord coord )
{
if ( !_live.Remove( coord, out var chunk ) )
return;
foreach ( var sceneObject in chunk.SceneObjects )
{
if ( sceneObject.IsValid() )
sceneObject.Delete();
}
chunk.SceneObjects.Clear();
chunk.Instances.Clear();
}
private void ReleaseAllChunks()
{
foreach ( var (_, chunk) in _live )
{
foreach ( var sceneObject in chunk.SceneObjects )
{
if ( sceneObject.IsValid() )
sceneObject.Delete();
}
}
_live.Clear();
_wantedChunks.Clear();
_staleChunks.Clear();
}
/// <summary>
/// Called by the editor tool after painting, so the next frame regenerates. Also fires when the
/// seed changes.
/// </summary>
public void MarkDirty()
{
_builtRevision = -1;
_hasStreamOrigin = false;
}
/// <summary>Total instances currently streamed in. Useful when tuning density and stream radius.</summary>
public int LiveInstanceCount
{
get
{
var count = 0;
foreach ( var (_, chunk) in _live )
count += chunk.SceneObjects.Count;
return count;
}
}
protected override void DrawGizmos()
{
if ( !Gizmo.IsSelected || Storage is null || Storage.ChunkCount == 0 )
return;
Gizmo.Draw.Color = Color.Green.WithAlpha( 0.25f );
foreach ( var (coord, _) in Storage.Chunks )
{
var origin = FloraStorage.ChunkOrigin( coord );
var mins = WorldTransform.PointToLocal( new Vector3( origin.x, origin.y, 0 ) );
var maxs = WorldTransform.PointToLocal( new Vector3(
origin.x + FloraStorage.ChunkSize, origin.y + FloraStorage.ChunkSize, 0 ) );
Gizmo.Draw.LineBBox( new BBox( mins, maxs ) );
}
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.FloraTool;
/// <summary>
/// One kind of flora the brush can plant. Weight decides how often it comes up relative to the
/// other entries in the definition.
/// </summary>
public sealed class FloraEntry
{
[Property]
public Model Model { get; set; }
/// <summary>Relative chance of this entry being picked. Zero excludes it without deleting it.</summary>
[Property, Range( 0, 10 )]
public float Weight { get; set; } = 1.0f;
[Property]
public RangedFloat Scale { get; set; } = new( 0.85f, 1.25f );
/// <summary>Random spin about the vertical axis, so repeated instances don't read as clones.</summary>
[Property]
public bool RandomYaw { get; set; } = true;
/// <summary>
/// Tilts the instance toward the surface normal. Right for rocks and bushes, usually wrong for
/// trees - a trunk growing perpendicular to a hillside looks broken.
/// </summary>
[Property, Range( 0, 1 )]
public float AlignToNormal { get; set; } = 0.0f;
/// <summary>Random lean away from vertical, in degrees. A little goes a long way on trees.</summary>
[Property, Range( 0, 45 )]
public float RandomTilt { get; set; } = 0.0f;
/// <summary>Sinks the instance into the ground, hiding the seam where the base meets the surface.</summary>
[Property, Range( 0, 64 )]
public float SinkDepth { get; set; } = 0.0f;
/// <summary>
/// Gives this entry real collision. Colliders are only created near the player, so this is about
/// whether the flora is solid at all - not about paying for every painted instance at once.
/// </summary>
[Property, Group( "Physics" )]
public bool EnablePhysics { get; set; } = true;
[Property, Group( "Rendering" )]
public bool CastShadows { get; set; } = true;
public bool HasModel => Model is not null && !string.IsNullOrEmpty( Model.ResourcePath );
}
/// <summary>
/// A palette of flora plus the rules used when painting it. Shared by every
/// <see cref="FloraRenderer"/> that references it, so a whole world can be retuned from one asset.
/// </summary>
[AssetType( Name = "Flora Definition", Extension = "floradef", Category = "Flora" )]
public sealed class FloraDefinition : GameResource
{
[Property]
public List<FloraEntry> Entries { get; set; } = [];
/// <summary>
/// Instances a fully painted cell can hold. Coverage scales this, so it sets the ceiling on how
/// tightly flora can pack - raise it for undergrowth, leave it low for trees.
/// </summary>
[Property, Group( "Painting" ), Range( 1, 16 )]
public int MaxPerCell { get; set; } = 2;
/// <summary>Minimum ground normal Z. Steeper than this and nothing plants, so cliffs stay bare.</summary>
[Property, Group( "Painting" ), Range( 0, 1 )]
public float SlopeLimit { get; set; } = 0.6f;
/// <summary>
/// Radius around the viewer within which chunks are turned into scene objects. Chunks beyond it
/// keep their painted coverage but cost nothing to render.
/// </summary>
[Property, Group( "Streaming" ), Range( 2000, 100000 )]
public float StreamRadius { get; set; } = 25000.0f;
/// <summary>
/// Distance past which flora stops casting shadows. Shadow cascades ignore the view frustum, so
/// distant trees are rendered into them whichever way the camera faces - dropping them is one of
/// the few savings that applies even when you are looking away.
///
/// Set it too low and you will see shadows wink out as chunks cross the boundary, most obviously
/// under a low sun where far geometry casts long shadows into view. Zero disables the cutoff.
/// </summary>
[Property, Group( "Streaming" ), Range( 0, 50000 )]
public float ShadowDistance { get; set; } = 10000.0f;
/// <summary>
/// Radius around the viewer within which entries flagged <see cref="FloraEntry.EnablePhysics"/>
/// get real colliders. Keep it just past where the player can reach.
/// </summary>
[Property, Group( "Physics" ), Range( 256, 20000 )]
public float CollisionRadius { get; set; } = 4000.0f;
/// <summary>
/// The entry at an index, or null when the index no longer resolves - entries can be removed
/// after coverage has already been painted naming them.
/// </summary>
public FloraEntry GetEntry( int index )
{
if ( Entries is null || index < 0 || index >= Entries.Count )
return null;
var entry = Entries[index];
return entry?.HasModel is true ? entry : null;
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.FloraTool;
/// <summary>
/// Painted flora coverage, stored as a sparse chunked grid of density samples rather than one
/// transform per tree. Instances are regenerated from this plus a seed, so a forest of a hundred
/// thousand trees costs a few megabytes instead of tens - which matters because the scene sidecar
/// has to survive being committed to a repository.
///
/// The trade is that positions are derived, not authored: painting decides where flora *can* grow
/// and how densely, and the seed decides exactly where each trunk lands.
/// </summary>
public sealed class FloraStorage : BlobData
{
public override int Version => 1;
/// <summary>Cells along one edge of a chunk.</summary>
public const int ChunkResolution = 32;
/// <summary>
/// World size of one density cell. Roughly a tree's footprint - each cell holds at most a
/// handful of instances, so this is what bounds how tightly flora can pack.
/// Changing it invalidates every painted scene, so it is a constant rather than a setting.
/// </summary>
public const float CellSize = 256.0f;
public const float ChunkSize = ChunkResolution * CellSize;
public const int CellsPerChunk = ChunkResolution * ChunkResolution;
/// <summary>
/// One coverage sample. Height and normal are baked at paint time so flora sits on whatever
/// geometry was there, without the renderer having to trace anything at load.
/// </summary>
public struct Cell
{
public float Height;
/// <summary>density (0-7) | normal.x (8-15) | normal.y (16-23) | entry index (24-31)</summary>
public uint Packed;
public readonly float Density => (Packed & 0xFF) / 255.0f;
/// <summary>Index into the definition's entry list. 0xFF means "pick one by weight".</summary>
public readonly int EntryIndex => (int)((Packed >> 24) & 0xFF);
public readonly Vector3 Normal
{
get
{
var x = ((Packed >> 8) & 0xFF) / 127.5f - 1.0f;
var y = ((Packed >> 16) & 0xFF) / 127.5f - 1.0f;
var z = MathF.Sqrt( Math.Clamp( 1.0f - x * x - y * y, 0.0f, 1.0f ) );
return new Vector3( x, y, z );
}
}
public static uint Pack( float density, Vector3 normal, int entryIndex )
{
var d = (uint)Math.Clamp( density * 255.0f + 0.5f, 0.0f, 255.0f );
var nx = (uint)Math.Clamp( (normal.x + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );
var ny = (uint)Math.Clamp( (normal.y + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );
var e = (uint)Math.Clamp( entryIndex, 0, 255 );
return d | (nx << 8) | (ny << 16) | (e << 24);
}
}
public readonly record struct ChunkCoord( int X, int Y );
private readonly Dictionary<ChunkCoord, Cell[]> _chunks = [];
/// <summary>Bumped on every mutation so the renderer knows to regenerate.</summary>
public int Revision { get; private set; }
public int ChunkCount => _chunks.Count;
public IReadOnlyDictionary<ChunkCoord, Cell[]> Chunks => _chunks;
public static ChunkCoord WorldToChunk( Vector3 world ) => new(
(int)MathF.Floor( world.x / ChunkSize ),
(int)MathF.Floor( world.y / ChunkSize ) );
public static Vector2 ChunkOrigin( ChunkCoord coord ) => new( coord.X * ChunkSize, coord.Y * ChunkSize );
public static Vector3 ChunkCenter( ChunkCoord coord, float height = 0.0f )
{
var origin = ChunkOrigin( coord );
return new Vector3( origin.x + ChunkSize * 0.5f, origin.y + ChunkSize * 0.5f, height );
}
private static int WorldToCell( float world ) => (int)MathF.Floor( world / CellSize );
private static int FloorDiv( int a, int b ) => a >= 0 ? a / b : ~(~a / b);
private static int Mod( int a, int b )
{
var r = a % b;
return r < 0 ? r + b : r;
}
/// <summary>
/// Writes a coverage sample, baking the surface height and normal alongside it. Density of zero
/// frees the sample.
/// </summary>
public void SetCell( float worldX, float worldY, float density, float height, Vector3 normal, int entryIndex )
{
var cellX = WorldToCell( worldX );
var cellY = WorldToCell( worldY );
var coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );
if ( !_chunks.TryGetValue( coord, out var cells ) )
{
if ( density <= 0.0f ) return;
cells = new Cell[CellsPerChunk];
_chunks[coord] = cells;
}
var index = Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution );
cells[index] = new Cell { Height = height, Packed = Cell.Pack( density, normal, entryIndex ) };
Revision++;
}
public Cell GetCell( float worldX, float worldY )
{
var cellX = WorldToCell( worldX );
var cellY = WorldToCell( worldY );
var coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );
if ( !_chunks.TryGetValue( coord, out var cells ) )
return default;
return cells[Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution )];
}
/// <summary>Reduces coverage in a radius, removing samples that reach zero.</summary>
public void Erase( Vector3 center, float radius, float strength )
{
var radiusSquared = radius * radius;
var minCellX = WorldToCell( center.x - radius );
var maxCellX = WorldToCell( center.x + radius );
var minCellY = WorldToCell( center.y - radius );
var maxCellY = WorldToCell( center.y + radius );
var changed = false;
for ( var cy = minCellY; cy <= maxCellY; cy++ )
{
for ( var cx = minCellX; cx <= maxCellX; cx++ )
{
var coord = new ChunkCoord( FloorDiv( cx, ChunkResolution ), FloorDiv( cy, ChunkResolution ) );
if ( !_chunks.TryGetValue( coord, out var cells ) )
continue;
var wx = (cx + 0.5f) * CellSize;
var wy = (cy + 0.5f) * CellSize;
var dx = wx - center.x;
var dy = wy - center.y;
if ( dx * dx + dy * dy > radiusSquared )
continue;
var index = Mod( cy, ChunkResolution ) * ChunkResolution + Mod( cx, ChunkResolution );
ref var cell = ref cells[index];
if ( (cell.Packed & 0xFF) == 0 )
continue;
var density = Math.Max( cell.Density - strength, 0.0f );
cell.Packed = density <= 0.0f
? 0u
: Cell.Pack( density, cell.Normal, cell.EntryIndex );
changed = true;
}
}
if ( !changed )
return;
PruneEmptyChunks();
Revision++;
}
public void ClearAll()
{
if ( _chunks.Count == 0 ) return;
_chunks.Clear();
Revision++;
}
private void PruneEmptyChunks()
{
List<ChunkCoord> empty = null;
foreach ( var (coord, cells) in _chunks )
{
var used = false;
for ( var i = 0; i < cells.Length; i++ )
{
if ( (cells[i].Packed & 0xFF) != 0 ) { used = true; break; }
}
if ( !used )
{
empty ??= [];
empty.Add( coord );
}
}
if ( empty is null ) return;
foreach ( var coord in empty )
_chunks.Remove( coord );
}
/// <summary>
/// Writes only the painted cells. Storing them densely cost 8KB per chunk however little of it
/// was painted, and a brush stroke across a landscape touches a lot of chunks.
///
/// Each painted cell costs 2 bytes more than it did dense (its index), so a chunk past about 80%
/// coverage is cheaper stored densely. Both layouts are written and each chunk says which it used.
/// </summary>
public override void Serialize( ref Writer writer )
{
writer.Stream.Write( _chunks.Count );
foreach ( var (coord, cells) in _chunks )
{
writer.Stream.Write( coord.X );
writer.Stream.Write( coord.Y );
var painted = 0;
for ( var i = 0; i < CellsPerChunk; i++ )
{
if ( (cells[i].Packed & 0xFF) != 0 ) painted++;
}
var sparse = painted * 10 < CellsPerChunk * 8;
writer.Stream.Write( sparse );
if ( !sparse )
{
for ( var i = 0; i < CellsPerChunk; i++ )
{
writer.Stream.Write( cells[i].Height );
writer.Stream.Write( cells[i].Packed );
}
continue;
}
writer.Stream.Write( painted );
for ( var i = 0; i < CellsPerChunk; i++ )
{
if ( (cells[i].Packed & 0xFF) == 0 )
continue;
writer.Stream.Write( (ushort)i );
writer.Stream.Write( cells[i].Height );
writer.Stream.Write( cells[i].Packed );
}
}
}
public override void Deserialize( ref Reader reader )
{
_chunks.Clear();
var chunkCount = reader.Stream.Read<int>();
for ( var c = 0; c < chunkCount; c++ )
{
var coord = new ChunkCoord( reader.Stream.Read<int>(), reader.Stream.Read<int>() );
var cells = new Cell[CellsPerChunk];
if ( reader.Stream.Read<bool>() )
{
var painted = reader.Stream.Read<int>();
for ( var p = 0; p < painted; p++ )
{
var index = reader.Stream.Read<ushort>();
var height = reader.Stream.Read<float>();
var packed = reader.Stream.Read<uint>();
if ( index < CellsPerChunk )
{
cells[index].Height = height;
cells[index].Packed = packed;
}
}
}
else
{
for ( var i = 0; i < CellsPerChunk; i++ )
{
cells[i].Height = reader.Stream.Read<float>();
cells[i].Packed = reader.Stream.Read<uint>();
}
}
_chunks[coord] = cells;
}
Revision++;
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.FloraTool;
/// <summary>
/// Collision for painted flora. Instances only exist as scene objects, so nothing is solid until a
/// collider is made for it - and those are made only for instances near the viewer and recycled as
/// it moves, keeping physics cost tied to what is reachable rather than to the whole forest.
/// </summary>
public sealed partial class FloraRenderer
{
private readonly record struct CollisionKey( FloraStorage.ChunkCoord Chunk, 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> _wantedColliders = [];
private readonly List<CollisionKey> _staleColliders = [];
private GameObject _collisionRoot;
private Vector3 _lastCollisionOrigin;
private bool _hasCollisionOrigin;
private int _collisionRevision = -1;
/// <summary>
/// Rebuilding walks every streamed 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 ( !Definition.IsValid() || Definition.CollisionRadius <= 0.0f )
{
ReleaseCollision();
return;
}
var storageChanged = Storage is null || _collisionRevision != Storage.Revision;
if ( !storageChanged && _hasCollisionOrigin &&
origin.Distance( _lastCollisionOrigin ) < CollisionRefreshDistance )
return;
_collisionRevision = Storage?.Revision ?? -1;
_lastCollisionOrigin = origin;
_hasCollisionOrigin = true;
GatherWantedColliders( origin );
SyncColliders();
}
/// <summary>
/// Only streamed chunks are considered. Collision radius should sit well inside the stream radius
/// anyway, so anything outside it has no business being solid.
/// </summary>
private void GatherWantedColliders( Vector3 origin )
{
_wantedColliders.Clear();
var radiusSquared = Definition.CollisionRadius * Definition.CollisionRadius;
foreach ( var (coord, chunk) in _live )
{
for ( var i = 0; i < chunk.Instances.Count; i++ )
{
var instance = chunk.Instances[i];
if ( instance.Position.DistanceSquared( origin ) > radiusSquared )
continue;
var entry = Definition.GetEntry( instance.EntryIndex );
if ( entry?.EnablePhysics is not true )
continue;
_wantedColliders.Add( new CollisionKey( coord, i ) );
}
}
}
private void SyncColliders()
{
// Drop what fell out of range first, so those objects are free to be reused this same frame.
_staleColliders.Clear();
foreach ( var (key, gameObject) in _colliders )
{
if ( gameObject.IsValid() && _wantedColliders.Contains( key ) )
continue;
_staleColliders.Add( key );
}
foreach ( var key in _staleColliders )
{
if ( _colliders.Remove( key, out var gameObject ) && gameObject.IsValid() )
gameObject.Destroy();
}
foreach ( var key in _wantedColliders )
{
if ( _colliders.ContainsKey( key ) )
continue;
var gameObject = CreateCollider( key );
if ( gameObject.IsValid() )
_colliders[key] = gameObject;
}
}
private GameObject CreateCollider( CollisionKey key )
{
if ( !_live.TryGetValue( key.Chunk, out var chunk ) )
return null;
if ( key.Index < 0 || key.Index >= chunk.Instances.Count )
return null;
var instance = chunk.Instances[key.Index];
var entry = Definition.GetEntry( instance.EntryIndex );
if ( entry is null )
return null;
EnsureCollisionRoot();
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 is regenerated from the seed anyway.
gameObject.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;
var collider = gameObject.Components.Create<ModelCollider>();
collider.Model = entry.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();
_wantedColliders.Clear();
_staleColliders.Clear();
if ( _collisionRoot.IsValid() )
_collisionRoot.Destroy();
_collisionRoot = null;
_hasCollisionOrigin = false;
_collisionRevision = -1;
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.FloraTool;
/// <summary>
/// Turns painted coverage into concrete instances. Everything here is a pure function of the chunk
/// coordinate, the cell contents and the seed - no state, no RNG object - so a chunk regenerates
/// identically every run, on every machine, however many times it is streamed in and out.
/// </summary>
public static class FloraGenerator
{
public readonly record struct Instance( int EntryIndex, Vector3 Position, Rotation Rotation, float Scale )
{
public readonly Transform ToTransform() => new( Position, Rotation, Scale );
}
/// <summary>
/// Integer avalanche hash. Deterministic across runs and platforms, which the framework RNG is
/// not guaranteed to be, and cheap enough to call several times per instance.
/// </summary>
private static uint Hash( uint x )
{
x ^= x >> 16;
x *= 0x7feb352du;
x ^= x >> 15;
x *= 0x846ca68bu;
x ^= x >> 16;
return x;
}
private static float HashFloat( uint x ) => Hash( x ) * (1.0f / 4294967296.0f);
/// <summary>
/// Generates every instance for one chunk, appending into <paramref name="results"/>.
/// </summary>
public static void GenerateChunk( FloraStorage.ChunkCoord coord, FloraStorage.Cell[] cells,
FloraDefinition definition, int seed, List<Instance> results )
{
if ( cells is null || definition is null )
return;
var origin = FloraStorage.ChunkOrigin( coord );
var maxPerCell = Math.Max( definition.MaxPerCell, 1 );
// Mixing the chunk coordinate into the seed keeps neighbouring chunks from sharing a
// sequence, which would otherwise show up as a visible repeating pattern across the world.
var chunkSeed = Hash( (uint)seed
^ Hash( (uint)coord.X * 73856093u )
^ Hash( (uint)coord.Y * 19349663u ) );
for ( var cellIndex = 0; cellIndex < cells.Length; cellIndex++ )
{
var cell = cells[cellIndex];
var density = cell.Density;
if ( density <= 0.0f )
continue;
if ( cell.Normal.z < definition.SlopeLimit )
continue;
var cellSeed = Hash( chunkSeed ^ Hash( (uint)cellIndex * 0x9e3779b9u ) );
var cx = cellIndex % FloraStorage.ChunkResolution;
var cy = cellIndex / FloraStorage.ChunkResolution;
var cellMinX = origin.x + cx * FloraStorage.CellSize;
var cellMinY = origin.y + cy * FloraStorage.CellSize;
// Fractional counts are resolved by a hash rather than rounding, so density reads as a
// smooth thinning across a field instead of stepping between whole numbers per cell.
var exact = density * maxPerCell;
var count = (int)exact;
if ( HashFloat( cellSeed ^ 0x1b56c4e9u ) < exact - count )
count++;
for ( var i = 0; i < count; i++ )
{
var s = Hash( cellSeed + (uint)i * 0x85ebca6bu );
var entry = ResolveEntry( definition, cell.EntryIndex, s );
if ( entry.Index < 0 )
continue;
results.Add( BuildInstance( entry.Index, entry.Entry, cell, s, cellMinX, cellMinY ) );
}
}
}
/// <summary>
/// A cell either names its entry - painted deliberately with one species selected - or defers to
/// the definition's weights.
/// </summary>
private static (int Index, FloraEntry Entry) ResolveEntry( FloraDefinition definition, int cellEntryIndex, uint seed )
{
var entries = definition.Entries;
if ( entries is null || entries.Count == 0 )
return (-1, null);
if ( cellEntryIndex < entries.Count )
{
var named = entries[cellEntryIndex];
return named?.HasModel is true ? (cellEntryIndex, named) : (-1, null);
}
var total = 0.0f;
for ( var i = 0; i < entries.Count; i++ )
{
if ( entries[i]?.HasModel is true && entries[i].Weight > 0.0f )
total += entries[i].Weight;
}
if ( total <= 0.0f )
return (-1, null);
var pick = HashFloat( seed ^ 0x3c6ef372u ) * total;
for ( var i = 0; i < entries.Count; i++ )
{
var entry = entries[i];
if ( entry?.HasModel is not true || entry.Weight <= 0.0f )
continue;
pick -= entry.Weight;
if ( pick <= 0.0f )
return (i, entry);
}
return (-1, null);
}
private static Instance BuildInstance( int entryIndex, FloraEntry entry, FloraStorage.Cell cell,
uint seed, float cellMinX, float cellMinY )
{
var jitterX = HashFloat( seed ^ 0x68bc21ebu );
var jitterY = HashFloat( seed ^ 0x02e5be93u );
var x = cellMinX + jitterX * FloraStorage.CellSize;
var y = cellMinY + jitterY * FloraStorage.CellSize;
var normal = cell.Normal;
// The baked height is the cell centre's, so a slope needs the offset carried across to the
// jittered position or trunks float on the uphill side and sink on the downhill one.
var offsetX = x - (cellMinX + FloraStorage.CellSize * 0.5f);
var offsetY = y - (cellMinY + FloraStorage.CellSize * 0.5f);
var z = cell.Height - (normal.x * offsetX + normal.y * offsetY) / MathF.Max( normal.z, 0.1f );
var position = new Vector3( x, y, z );
if ( entry.SinkDepth > 0.0f )
position -= normal * entry.SinkDepth;
var rotation = entry.RandomYaw
? Rotation.FromYaw( HashFloat( seed ^ 0x7f4a7c15u ) * 360.0f )
: Rotation.Identity;
if ( entry.AlignToNormal > 0.0f )
{
var aligned = Rotation.LookAt( normal ) * Rotation.FromPitch( 90.0f );
rotation = Rotation.Slerp( rotation, aligned * rotation, entry.AlignToNormal );
}
if ( entry.RandomTilt > 0.0f )
{
var tiltAngle = HashFloat( seed ^ 0x165667b1u ) * entry.RandomTilt;
var tiltDirection = HashFloat( seed ^ 0x27d4eb2fu ) * 360.0f;
rotation *= Rotation.FromAxis( Rotation.FromYaw( tiltDirection ).Forward, tiltAngle );
}
var scale = MathX.Lerp( entry.Scale.Min, entry.Scale.Max, HashFloat( seed ^ 0xd3a2646cu ) );
return new Instance( entryIndex, position, rotation, scale );
}
}
Editor
library
using System;
using System.Linq;
using Editor;
using Editor.TerrainEditor;
using Sandbox;
namespace RedSnail.FloraTool.Editor;
/// <summary>
/// Paints flora onto any surface. Each stroke scatters entries from the target renderer's
/// definition, honouring its spacing and slope rules, and bakes the resulting transform into the
/// renderer's storage. Hold Ctrl to erase.
/// </summary>
[EditorTool( "flora" )]
[Title( "Flora" )]
[Icon( "park" )]
public sealed class FloraPaintTool : EditorTool
{
public BrushSettings BrushSettings { get; private set; } = new();
private FloraRenderer _target;
private bool _erasing;
private bool _dragging;
private bool _painted;
private Vector3 _lastPaintPosition;
private ComboBox _entryDropdown;
/// <summary>Index into the definition's entries, or 255 for "mix by weight".</summary>
private int _entryIndex = MixedEntryIndex;
private const int MixedEntryIndex = 255;
// The brush has to travel a fraction of its own radius before depositing again, or holding the
// mouse still would keep hammering the same spot with traces.
private float PaintStepDistance => BrushSettings.Size * 0.35f;
public FloraPaintTool()
{
RebuildSidebarOnSelectionChange = false;
}
public override Widget CreateToolSidebar()
{
var sidebar = new ToolSidebarWidget();
sidebar.AddTitle( "Flora Brush", "brush" );
sidebar.MinimumWidth = 300;
{
var group = sidebar.AddGroup( "Brush" );
var so = BrushSettings.GetSerialized();
group.Add( ControlSheet.CreateRow( so.GetProperty( nameof( BrushSettings.Size ) ) ) );
group.Add( ControlSheet.CreateRow( so.GetProperty( nameof( BrushSettings.Opacity ) ) ) );
}
{
// Coverage names the entry it was painted with, so an artist can lay down pines here and
// oaks there rather than getting one weighted mix everywhere.
var group = sidebar.AddGroup( "Entry" );
_entryDropdown = new ComboBox( sidebar );
_entryDropdown.ToolTip = "Which flora entry this stroke paints. Mixed uses the definition's weights.";
RebuildEntryOptions();
group.Add( _entryDropdown );
}
{
var group = sidebar.AddGroup( "Actions" );
var clear = new Button( "Clear All Flora", "delete_sweep" );
clear.ToolTip = "Remove every painted instance from the target Flora Renderer";
clear.Clicked += () =>
{
var target = ResolveTarget();
if ( !target.IsValid() || target.Storage is null )
return;
// Wiping the whole painted set has no undo, so this one asks first.
Dialog.AskConfirm(
() =>
{
target.Storage.ClearAll();
target.MarkDirty();
},
"Are you sure you want to delete all flora? This action cannot be undone.",
"Delete All Flora",
"Delete",
"Cancel" );
};
group.Add( clear );
}
sidebar.Layout.AddStretchCell();
return sidebar;
}
public override void OnUpdate()
{
_erasing = Gizmo.IsCtrlPressed;
DrawBrushPreview();
Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 999999 ) );
if ( Gizmo.IsLeftMouseDown )
{
if ( !_dragging )
{
_dragging = true;
_lastPaintPosition = Vector3.Zero;
}
OnPaintUpdate();
}
else if ( _dragging )
{
_dragging = false;
_lastPaintPosition = Vector3.Zero;
if ( _painted )
{
ResolveTarget()?.MarkDirty();
_painted = false;
}
}
}
/// <summary>
/// Uses the selected renderer when there is one, otherwise the last used, otherwise the only one
/// in the scene. Creating one implicitly would leave stray components behind every time someone
/// opens the tool.
/// </summary>
private FloraRenderer ResolveTarget()
{
var selected = Selection
.OfType<GameObject>()
.Select( go => go.Components.Get<FloraRenderer>( FindMode.EnabledInSelfAndDescendants ) )
.FirstOrDefault( r => r.IsValid() );
if ( selected.IsValid() )
{
_target = selected;
return _target;
}
if ( _target.IsValid() )
return _target;
_target = Scene.GetAllComponents<FloraRenderer>().FirstOrDefault();
return _target;
}
private void OnPaintUpdate()
{
var target = ResolveTarget();
if ( !target.IsValid() || target.Storage is null || !target.Definition.IsValid() )
return;
var cursor = TraceCursor();
if ( !cursor.Hit )
return;
if ( _lastPaintPosition != Vector3.Zero &&
Vector3.DistanceBetween( cursor.HitPosition, _lastPaintPosition ) < PaintStepDistance )
return;
_lastPaintPosition = cursor.HitPosition;
var radius = (float)BrushSettings.Size;
var strength = BrushSettings.Opacity;
if ( _erasing )
{
target.Storage.Erase( cursor.HitPosition, radius, strength );
_painted = true;
return;
}
PaintCoverage( target, cursor.HitPosition, radius, strength );
_painted = true;
}
/// <summary>
/// Walks every coverage cell the brush touches and traces straight down onto the world, baking
/// the surface height and normal so instances sit on whatever geometry is there. Nothing is
/// placed here - the renderer derives the actual trunks from this coverage plus its seed.
/// </summary>
private void PaintCoverage( FloraRenderer target, Vector3 center, float radius, float strength )
{
var definition = target.Definition;
var storage = target.Storage;
var radiusSquared = radius * radius;
var minX = (int)MathF.Floor( (center.x - radius) / FloraStorage.CellSize );
var maxX = (int)MathF.Floor( (center.x + radius) / FloraStorage.CellSize );
var minY = (int)MathF.Floor( (center.y - radius) / FloraStorage.CellSize );
var maxY = (int)MathF.Floor( (center.y + radius) / FloraStorage.CellSize );
// Enough headroom to find the surface from above without punching through overhangs the
// brush was never aimed at.
var traceHeight = radius + 2048.0f;
for ( var cy = minY; cy <= maxY; cy++ )
{
for ( var cx = minX; cx <= maxX; cx++ )
{
var wx = (cx + 0.5f) * FloraStorage.CellSize;
var wy = (cy + 0.5f) * FloraStorage.CellSize;
var dx = wx - center.x;
var dy = wy - center.y;
var distSq = dx * dx + dy * dy;
if ( distSq > radiusSquared )
continue;
var from = new Vector3( wx, wy, center.z + traceHeight );
var to = new Vector3( wx, wy, center.z - traceHeight );
var tr = Scene.Trace.Ray( from, to )
.UseRenderMeshes( true )
.WithTag( "solid" )
.Run();
if ( !tr.Hit )
continue;
if ( tr.Normal.z < definition.SlopeLimit )
continue;
// Soft edge, so overlapping strokes build up smoothly instead of leaving a disc.
var falloff = 1.0f - MathF.Sqrt( distSq ) / radius;
var added = strength * MathF.Pow( falloff, 0.5f );
var existing = storage.GetCell( wx, wy ).Density;
var density = Math.Clamp( existing + added, 0.0f, 1.0f );
storage.SetCell( wx, wy, density, tr.HitPosition.z, tr.Normal, _entryIndex );
}
}
}
/// <summary>
/// Fills the entry dropdown from the target definition. Rebuilt on demand, since entries can be
/// added or changed while the tool is open.
/// </summary>
private void RebuildEntryOptions()
{
if ( _entryDropdown is null )
return;
_entryDropdown.Clear();
_entryDropdown.AddItem( "Mixed (by weight)", "shuffle", () => _entryIndex = MixedEntryIndex );
var definition = ResolveTarget()?.Definition;
if ( !definition.IsValid() || definition.Entries is null )
return;
for ( var i = 0; i < definition.Entries.Count; i++ )
{
var entry = definition.Entries[i];
if ( entry?.HasModel is not true )
continue;
var index = i;
var name = System.IO.Path.GetFileNameWithoutExtension( entry.Model.ResourcePath );
_entryDropdown.AddItem( name, "park", () => _entryIndex = index );
}
}
private SceneTraceResult TraceCursor() =>
Scene.Trace.Ray( Gizmo.CurrentRay, 100000 )
.UseRenderMeshes( true )
.WithTag( "solid" )
.Run();
private void DrawBrushPreview()
{
var tr = TraceCursor();
if ( !tr.Hit )
return;
using ( Gizmo.Scope( "FloraBrush" ) )
{
Gizmo.Draw.Color = _erasing
? Color.FromBytes( 250, 150, 150 )
: Color.FromBytes( 160, 230, 150 );
Gizmo.Draw.LineCircle( tr.HitPosition + tr.Normal * 1.0f, tr.Normal, BrushSettings.Size );
Gizmo.Draw.LineCircle( tr.HitPosition + tr.Normal * 1.0f, tr.Normal, BrushSettings.Size * 0.5f );
}
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.FloraTool;
/// <summary>
/// Renders painted flora. Coverage is stored per chunk and instances are regenerated from it plus
/// <see cref="Seed"/>, so the scene file holds a density map rather than a transform per tree - the
/// difference between a few megabytes and something a repository will refuse.
///
/// Chunks become scene objects only within the definition's stream radius. Scene objects rather than
/// a hand-rolled instanced draw because they take part in every pass the engine runs: the depth
/// prepass, the shadow cascades, and per-object LOD using the model's own compiled distances.
/// Standard instancing still batches them into few draw calls.
/// </summary>
[Icon( "park" ), Group( "Flora" ), Title( "Flora Renderer" )]
public sealed partial class FloraRenderer : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer
{
/// <summary>A chunk's generated instances and the scene objects currently standing for them.</summary>
private sealed class LiveChunk
{
public List<FloraGenerator.Instance> Instances = [];
public List<SceneObject> SceneObjects = [];
/// <summary>
/// Whether this chunk is currently allowed to cast. Tracked so the flags are only touched
/// when a chunk crosses the shadow boundary, rather than every object every frame.
/// </summary>
public bool ShadowsEnabled = true;
}
[Property, Group( "General" )]
public FloraDefinition Definition { get; set; }
/// <summary>
/// Decides exactly where each instance lands within the painted coverage. Change it to reshuffle
/// a whole forest without repainting; keep it fixed and the same trees stand in the same places
/// every run, on every machine.
/// </summary>
[Property, Group( "General" )]
public int Seed
{
get => field;
set
{
if ( field == value ) return;
field = value;
MarkDirty();
}
}
/// <summary>Painted coverage. Serialized as a binary blob, not JSON.</summary>
[Property, Hide]
public FloraStorage Storage { get; set; } = new();
private readonly Dictionary<FloraStorage.ChunkCoord, LiveChunk> _live = [];
private readonly List<FloraStorage.ChunkCoord> _wantedChunks = [];
private readonly List<FloraStorage.ChunkCoord> _staleChunks = [];
// Reused across chunk builds so streaming doesn't allocate a fresh list per chunk.
private readonly List<FloraGenerator.Instance> _scratchInstances = [];
private int _builtRevision = -1;
private Vector3 _lastStreamOrigin;
private bool _hasStreamOrigin;
/// <summary>
/// Restreaming walks every painted chunk, so it only happens once the viewer has moved far enough
/// for the answer to have changed. A fraction of a chunk keeps the boundary from thrashing.
/// </summary>
private const float StreamRefreshDistance = FloraStorage.ChunkSize * 0.25f;
protected override void OnEnabled()
{
Storage ??= new FloraStorage();
// 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;
_hasStreamOrigin = false;
}
protected override void OnDisabled()
{
ReleaseAllChunks();
ReleaseCollision();
_builtRevision = -1;
_hasStreamOrigin = false;
}
protected override void OnUpdate()
{
var viewer = GetViewerPosition();
if ( !viewer.HasValue )
return;
UpdateStreaming( viewer.Value );
UpdateCollision( viewer.Value );
}
/// <summary>
/// What streaming follows. While editing that is the viewport camera, so flora appears around
/// 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;
}
private void UpdateStreaming( Vector3 origin )
{
if ( Storage is null || !Definition.IsValid() )
{
ReleaseAllChunks();
return;
}
// Painting or reseeding invalidates everything regardless of whether the viewer moved.
var dirty = _builtRevision != Storage.Revision;
if ( !dirty && _hasStreamOrigin && origin.Distance( _lastStreamOrigin ) < StreamRefreshDistance )
return;
if ( dirty )
{
ReleaseAllChunks();
_builtRevision = Storage.Revision;
}
_lastStreamOrigin = origin;
_hasStreamOrigin = true;
GatherWantedChunks( origin );
SyncChunks( origin );
}
private void GatherWantedChunks( Vector3 origin )
{
_wantedChunks.Clear();
// A chunk's near corner can be in range while its centre is not, hence the circumradius.
var radius = Definition.StreamRadius + FloraStorage.ChunkSize * 0.7072f;
var radiusSquared = radius * radius;
foreach ( var (coord, _) in Storage.Chunks )
{
var center = FloraStorage.ChunkCenter( coord );
var dx = center.x - origin.x;
var dy = center.y - origin.y;
if ( dx * dx + dy * dy > radiusSquared )
continue;
_wantedChunks.Add( coord );
}
}
private void SyncChunks( Vector3 origin )
{
_staleChunks.Clear();
foreach ( var (coord, _) in _live )
{
if ( !_wantedChunks.Contains( coord ) )
_staleChunks.Add( coord );
}
foreach ( var coord in _staleChunks )
ReleaseChunk( coord );
foreach ( var coord in _wantedChunks )
{
if ( _live.ContainsKey( coord ) )
continue;
BuildChunk( coord, origin );
}
UpdateChunkShadows( origin );
}
/// <summary>
/// Turns shadow casting off for chunks past the shadow distance. Evaluated per chunk rather than
/// per instance, and only written when a chunk actually crosses the boundary, so a stationary
/// camera costs nothing here.
/// </summary>
private void UpdateChunkShadows( Vector3 origin )
{
foreach ( var (coord, chunk) in _live )
{
var wanted = ChunkCastsShadows( coord, origin );
if ( wanted == chunk.ShadowsEnabled )
continue;
chunk.ShadowsEnabled = wanted;
ApplyChunkShadows( chunk );
}
}
private bool ChunkCastsShadows( FloraStorage.ChunkCoord coord, Vector3 origin )
{
var distance = Definition.ShadowDistance;
if ( distance <= 0.0f )
return true;
// Measured to the chunk's near edge via its circumradius, so a chunk is only cut off once all
// of it is beyond the limit.
var limit = distance + FloraStorage.ChunkSize * 0.7072f;
var center = FloraStorage.ChunkCenter( coord );
var dx = center.x - origin.x;
var dy = center.y - origin.y;
return dx * dx + dy * dy <= limit * limit;
}
/// <summary>
/// The per-entry CastShadows setting is the ceiling - distance can only ever take shadows away,
/// never grant them to an entry the artist turned them off for.
/// </summary>
private void ApplyChunkShadows( LiveChunk chunk )
{
for ( var i = 0; i < chunk.SceneObjects.Count && i < chunk.Instances.Count; i++ )
{
var sceneObject = chunk.SceneObjects[i];
if ( !sceneObject.IsValid() )
continue;
var entry = Definition.GetEntry( chunk.Instances[i].EntryIndex );
sceneObject.Flags.CastShadows = chunk.ShadowsEnabled && entry?.CastShadows is true;
}
}
private void BuildChunk( FloraStorage.ChunkCoord coord, Vector3 origin )
{
if ( !Storage.Chunks.TryGetValue( coord, out var cells ) )
return;
var world = Scene.SceneWorld;
if ( !world.IsValid() )
return;
var chunk = new LiveChunk();
chunk.ShadowsEnabled = ChunkCastsShadows( coord, origin );
_scratchInstances.Clear();
FloraGenerator.GenerateChunk( coord, cells, Definition, Seed, _scratchInstances );
// Instances and scene objects are kept strictly parallel - anything whose entry no longer
// resolves is dropped from both. Skipping only the scene object would slide the two lists out
// of step, and the shadow and collision paths index one by the other.
for ( var i = 0; i < _scratchInstances.Count; i++ )
{
var instance = _scratchInstances[i];
var entry = Definition.GetEntry( instance.EntryIndex );
if ( entry is null )
continue;
var sceneObject = new SceneObject( world, entry.Model, instance.ToTransform() );
sceneObject.Flags.CastShadows = chunk.ShadowsEnabled && entry.CastShadows;
chunk.Instances.Add( instance );
chunk.SceneObjects.Add( sceneObject );
}
_live[coord] = chunk;
}
private void ReleaseChunk( FloraStorage.ChunkCoord coord )
{
if ( !_live.Remove( coord, out var chunk ) )
return;
foreach ( var sceneObject in chunk.SceneObjects )
{
if ( sceneObject.IsValid() )
sceneObject.Delete();
}
chunk.SceneObjects.Clear();
chunk.Instances.Clear();
}
private void ReleaseAllChunks()
{
foreach ( var (_, chunk) in _live )
{
foreach ( var sceneObject in chunk.SceneObjects )
{
if ( sceneObject.IsValid() )
sceneObject.Delete();
}
}
_live.Clear();
_wantedChunks.Clear();
_staleChunks.Clear();
}
/// <summary>
/// Called by the editor tool after painting, so the next frame regenerates. Also fires when the
/// seed changes.
/// </summary>
public void MarkDirty()
{
_builtRevision = -1;
_hasStreamOrigin = false;
}
/// <summary>Total instances currently streamed in. Useful when tuning density and stream radius.</summary>
public int LiveInstanceCount
{
get
{
var count = 0;
foreach ( var (_, chunk) in _live )
count += chunk.SceneObjects.Count;
return count;
}
}
protected override void DrawGizmos()
{
if ( !Gizmo.IsSelected || Storage is null || Storage.ChunkCount == 0 )
return;
Gizmo.Draw.Color = Color.Green.WithAlpha( 0.25f );
foreach ( var (coord, _) in Storage.Chunks )
{
var origin = FloraStorage.ChunkOrigin( coord );
var mins = WorldTransform.PointToLocal( new Vector3( origin.x, origin.y, 0 ) );
var maxs = WorldTransform.PointToLocal( new Vector3(
origin.x + FloraStorage.ChunkSize, origin.y + FloraStorage.ChunkSize, 0 ) );
Gizmo.Draw.LineBBox( new BBox( mins, maxs ) );
}
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.FloraTool;
/// <summary>
/// One kind of flora the brush can plant. Weight decides how often it comes up relative to the
/// other entries in the definition.
/// </summary>
public sealed class FloraEntry
{
[Property]
public Model Model { get; set; }
/// <summary>Relative chance of this entry being picked. Zero excludes it without deleting it.</summary>
[Property, Range( 0, 10 )]
public float Weight { get; set; } = 1.0f;
[Property]
public RangedFloat Scale { get; set; } = new( 0.85f, 1.25f );
/// <summary>Random spin about the vertical axis, so repeated instances don't read as clones.</summary>
[Property]
public bool RandomYaw { get; set; } = true;
/// <summary>
/// Tilts the instance toward the surface normal. Right for rocks and bushes, usually wrong for
/// trees - a trunk growing perpendicular to a hillside looks broken.
/// </summary>
[Property, Range( 0, 1 )]
public float AlignToNormal { get; set; } = 0.0f;
/// <summary>Random lean away from vertical, in degrees. A little goes a long way on trees.</summary>
[Property, Range( 0, 45 )]
public float RandomTilt { get; set; } = 0.0f;
/// <summary>Sinks the instance into the ground, hiding the seam where the base meets the surface.</summary>
[Property, Range( 0, 64 )]
public float SinkDepth { get; set; } = 0.0f;
/// <summary>
/// Gives this entry real collision. Colliders are only created near the player, so this is about
/// whether the flora is solid at all - not about paying for every painted instance at once.
/// </summary>
[Property, Group( "Physics" )]
public bool EnablePhysics { get; set; } = true;
[Property, Group( "Rendering" )]
public bool CastShadows { get; set; } = true;
public bool HasModel => Model is not null && !string.IsNullOrEmpty( Model.ResourcePath );
}
/// <summary>
/// A palette of flora plus the rules used when painting it. Shared by every
/// <see cref="FloraRenderer"/> that references it, so a whole world can be retuned from one asset.
/// </summary>
[AssetType( Name = "Flora Definition", Extension = "floradef", Category = "Flora" )]
public sealed class FloraDefinition : GameResource
{
[Property]
public List<FloraEntry> Entries { get; set; } = [];
/// <summary>
/// Instances a fully painted cell can hold. Coverage scales this, so it sets the ceiling on how
/// tightly flora can pack - raise it for undergrowth, leave it low for trees.
/// </summary>
[Property, Group( "Painting" ), Range( 1, 16 )]
public int MaxPerCell { get; set; } = 2;
/// <summary>Minimum ground normal Z. Steeper than this and nothing plants, so cliffs stay bare.</summary>
[Property, Group( "Painting" ), Range( 0, 1 )]
public float SlopeLimit { get; set; } = 0.6f;
/// <summary>
/// Radius around the viewer within which chunks are turned into scene objects. Chunks beyond it
/// keep their painted coverage but cost nothing to render.
/// </summary>
[Property, Group( "Streaming" ), Range( 2000, 100000 )]
public float StreamRadius { get; set; } = 25000.0f;
/// <summary>
/// Distance past which flora stops casting shadows. Shadow cascades ignore the view frustum, so
/// distant trees are rendered into them whichever way the camera faces - dropping them is one of
/// the few savings that applies even when you are looking away.
///
/// Set it too low and you will see shadows wink out as chunks cross the boundary, most obviously
/// under a low sun where far geometry casts long shadows into view. Zero disables the cutoff.
/// </summary>
[Property, Group( "Streaming" ), Range( 0, 50000 )]
public float ShadowDistance { get; set; } = 10000.0f;
/// <summary>
/// Radius around the viewer within which entries flagged <see cref="FloraEntry.EnablePhysics"/>
/// get real colliders. Keep it just past where the player can reach.
/// </summary>
[Property, Group( "Physics" ), Range( 256, 20000 )]
public float CollisionRadius { get; set; } = 4000.0f;
/// <summary>
/// The entry at an index, or null when the index no longer resolves - entries can be removed
/// after coverage has already been painted naming them.
/// </summary>
public FloraEntry GetEntry( int index )
{
if ( Entries is null || index < 0 || index >= Entries.Count )
return null;
var entry = Entries[index];
return entry?.HasModel is true ? entry : null;
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.FloraTool;
/// <summary>
/// Collision for painted flora. Instances only exist as scene objects, so nothing is solid until a
/// collider is made for it - and those are made only for instances near the viewer and recycled as
/// it moves, keeping physics cost tied to what is reachable rather than to the whole forest.
/// </summary>
public sealed partial class FloraRenderer
{
private readonly record struct CollisionKey( FloraStorage.ChunkCoord Chunk, 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> _wantedColliders = [];
private readonly List<CollisionKey> _staleColliders = [];
private GameObject _collisionRoot;
private Vector3 _lastCollisionOrigin;
private bool _hasCollisionOrigin;
private int _collisionRevision = -1;
/// <summary>
/// Rebuilding walks every streamed 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 ( !Definition.IsValid() || Definition.CollisionRadius <= 0.0f )
{
ReleaseCollision();
return;
}
var storageChanged = Storage is null || _collisionRevision != Storage.Revision;
if ( !storageChanged && _hasCollisionOrigin &&
origin.Distance( _lastCollisionOrigin ) < CollisionRefreshDistance )
return;
_collisionRevision = Storage?.Revision ?? -1;
_lastCollisionOrigin = origin;
_hasCollisionOrigin = true;
GatherWantedColliders( origin );
SyncColliders();
}
/// <summary>
/// Only streamed chunks are considered. Collision radius should sit well inside the stream radius
/// anyway, so anything outside it has no business being solid.
/// </summary>
private void GatherWantedColliders( Vector3 origin )
{
_wantedColliders.Clear();
var radiusSquared = Definition.CollisionRadius * Definition.CollisionRadius;
foreach ( var (coord, chunk) in _live )
{
for ( var i = 0; i < chunk.Instances.Count; i++ )
{
var instance = chunk.Instances[i];
if ( instance.Position.DistanceSquared( origin ) > radiusSquared )
continue;
var entry = Definition.GetEntry( instance.EntryIndex );
if ( entry?.EnablePhysics is not true )
continue;
_wantedColliders.Add( new CollisionKey( coord, i ) );
}
}
}
private void SyncColliders()
{
// Drop what fell out of range first, so those objects are free to be reused this same frame.
_staleColliders.Clear();
foreach ( var (key, gameObject) in _colliders )
{
if ( gameObject.IsValid() && _wantedColliders.Contains( key ) )
continue;
_staleColliders.Add( key );
}
foreach ( var key in _staleColliders )
{
if ( _colliders.Remove( key, out var gameObject ) && gameObject.IsValid() )
gameObject.Destroy();
}
foreach ( var key in _wantedColliders )
{
if ( _colliders.ContainsKey( key ) )
continue;
var gameObject = CreateCollider( key );
if ( gameObject.IsValid() )
_colliders[key] = gameObject;
}
}
private GameObject CreateCollider( CollisionKey key )
{
if ( !_live.TryGetValue( key.Chunk, out var chunk ) )
return null;
if ( key.Index < 0 || key.Index >= chunk.Instances.Count )
return null;
var instance = chunk.Instances[key.Index];
var entry = Definition.GetEntry( instance.EntryIndex );
if ( entry is null )
return null;
EnsureCollisionRoot();
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 is regenerated from the seed anyway.
gameObject.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;
var collider = gameObject.Components.Create<ModelCollider>();
collider.Model = entry.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();
_wantedColliders.Clear();
_staleColliders.Clear();
if ( _collisionRoot.IsValid() )
_collisionRoot.Destroy();
_collisionRoot = null;
_hasCollisionOrigin = false;
_collisionRevision = -1;
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.FloraTool;
/// <summary>
/// Turns painted coverage into concrete instances. Everything here is a pure function of the chunk
/// coordinate, the cell contents and the seed - no state, no RNG object - so a chunk regenerates
/// identically every run, on every machine, however many times it is streamed in and out.
/// </summary>
public static class FloraGenerator
{
public readonly record struct Instance( int EntryIndex, Vector3 Position, Rotation Rotation, float Scale )
{
public readonly Transform ToTransform() => new( Position, Rotation, Scale );
}
/// <summary>
/// Integer avalanche hash. Deterministic across runs and platforms, which the framework RNG is
/// not guaranteed to be, and cheap enough to call several times per instance.
/// </summary>
private static uint Hash( uint x )
{
x ^= x >> 16;
x *= 0x7feb352du;
x ^= x >> 15;
x *= 0x846ca68bu;
x ^= x >> 16;
return x;
}
private static float HashFloat( uint x ) => Hash( x ) * (1.0f / 4294967296.0f);
/// <summary>
/// Generates every instance for one chunk, appending into <paramref name="results"/>.
/// </summary>
public static void GenerateChunk( FloraStorage.ChunkCoord coord, FloraStorage.Cell[] cells,
FloraDefinition definition, int seed, List<Instance> results )
{
if ( cells is null || definition is null )
return;
var origin = FloraStorage.ChunkOrigin( coord );
var maxPerCell = Math.Max( definition.MaxPerCell, 1 );
// Mixing the chunk coordinate into the seed keeps neighbouring chunks from sharing a
// sequence, which would otherwise show up as a visible repeating pattern across the world.
var chunkSeed = Hash( (uint)seed
^ Hash( (uint)coord.X * 73856093u )
^ Hash( (uint)coord.Y * 19349663u ) );
for ( var cellIndex = 0; cellIndex < cells.Length; cellIndex++ )
{
var cell = cells[cellIndex];
var density = cell.Density;
if ( density <= 0.0f )
continue;
if ( cell.Normal.z < definition.SlopeLimit )
continue;
var cellSeed = Hash( chunkSeed ^ Hash( (uint)cellIndex * 0x9e3779b9u ) );
var cx = cellIndex % FloraStorage.ChunkResolution;
var cy = cellIndex / FloraStorage.ChunkResolution;
var cellMinX = origin.x + cx * FloraStorage.CellSize;
var cellMinY = origin.y + cy * FloraStorage.CellSize;
// Fractional counts are resolved by a hash rather than rounding, so density reads as a
// smooth thinning across a field instead of stepping between whole numbers per cell.
var exact = density * maxPerCell;
var count = (int)exact;
if ( HashFloat( cellSeed ^ 0x1b56c4e9u ) < exact - count )
count++;
for ( var i = 0; i < count; i++ )
{
var s = Hash( cellSeed + (uint)i * 0x85ebca6bu );
var entry = ResolveEntry( definition, cell.EntryIndex, s );
if ( entry.Index < 0 )
continue;
results.Add( BuildInstance( entry.Index, entry.Entry, cell, s, cellMinX, cellMinY ) );
}
}
}
/// <summary>
/// A cell either names its entry - painted deliberately with one species selected - or defers to
/// the definition's weights.
/// </summary>
private static (int Index, FloraEntry Entry) ResolveEntry( FloraDefinition definition, int cellEntryIndex, uint seed )
{
var entries = definition.Entries;
if ( entries is null || entries.Count == 0 )
return (-1, null);
if ( cellEntryIndex < entries.Count )
{
var named = entries[cellEntryIndex];
return named?.HasModel is true ? (cellEntryIndex, named) : (-1, null);
}
var total = 0.0f;
for ( var i = 0; i < entries.Count; i++ )
{
if ( entries[i]?.HasModel is true && entries[i].Weight > 0.0f )
total += entries[i].Weight;
}
if ( total <= 0.0f )
return (-1, null);
var pick = HashFloat( seed ^ 0x3c6ef372u ) * total;
for ( var i = 0; i < entries.Count; i++ )
{
var entry = entries[i];
if ( entry?.HasModel is not true || entry.Weight <= 0.0f )
continue;
pick -= entry.Weight;
if ( pick <= 0.0f )
return (i, entry);
}
return (-1, null);
}
private static Instance BuildInstance( int entryIndex, FloraEntry entry, FloraStorage.Cell cell,
uint seed, float cellMinX, float cellMinY )
{
var jitterX = HashFloat( seed ^ 0x68bc21ebu );
var jitterY = HashFloat( seed ^ 0x02e5be93u );
var x = cellMinX + jitterX * FloraStorage.CellSize;
var y = cellMinY + jitterY * FloraStorage.CellSize;
var normal = cell.Normal;
// The baked height is the cell centre's, so a slope needs the offset carried across to the
// jittered position or trunks float on the uphill side and sink on the downhill one.
var offsetX = x - (cellMinX + FloraStorage.CellSize * 0.5f);
var offsetY = y - (cellMinY + FloraStorage.CellSize * 0.5f);
var z = cell.Height - (normal.x * offsetX + normal.y * offsetY) / MathF.Max( normal.z, 0.1f );
var position = new Vector3( x, y, z );
if ( entry.SinkDepth > 0.0f )
position -= normal * entry.SinkDepth;
var rotation = entry.RandomYaw
? Rotation.FromYaw( HashFloat( seed ^ 0x7f4a7c15u ) * 360.0f )
: Rotation.Identity;
if ( entry.AlignToNormal > 0.0f )
{
var aligned = Rotation.LookAt( normal ) * Rotation.FromPitch( 90.0f );
rotation = Rotation.Slerp( rotation, aligned * rotation, entry.AlignToNormal );
}
if ( entry.RandomTilt > 0.0f )
{
var tiltAngle = HashFloat( seed ^ 0x165667b1u ) * entry.RandomTilt;
var tiltDirection = HashFloat( seed ^ 0x27d4eb2fu ) * 360.0f;
rotation *= Rotation.FromAxis( Rotation.FromYaw( tiltDirection ).Forward, tiltAngle );
}
var scale = MathX.Lerp( entry.Scale.Min, entry.Scale.Max, HashFloat( seed ^ 0xd3a2646cu ) );
return new Instance( entryIndex, position, rotation, scale );
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.FloraTool;
/// <summary>
/// Painted flora coverage, stored as a sparse chunked grid of density samples rather than one
/// transform per tree. Instances are regenerated from this plus a seed, so a forest of a hundred
/// thousand trees costs a few megabytes instead of tens - which matters because the scene sidecar
/// has to survive being committed to a repository.
///
/// The trade is that positions are derived, not authored: painting decides where flora *can* grow
/// and how densely, and the seed decides exactly where each trunk lands.
/// </summary>
public sealed class FloraStorage : BlobData
{
public override int Version => 1;
/// <summary>Cells along one edge of a chunk.</summary>
public const int ChunkResolution = 32;
/// <summary>
/// World size of one density cell. Roughly a tree's footprint - each cell holds at most a
/// handful of instances, so this is what bounds how tightly flora can pack.
/// Changing it invalidates every painted scene, so it is a constant rather than a setting.
/// </summary>
public const float CellSize = 256.0f;
public const float ChunkSize = ChunkResolution * CellSize;
public const int CellsPerChunk = ChunkResolution * ChunkResolution;
/// <summary>
/// One coverage sample. Height and normal are baked at paint time so flora sits on whatever
/// geometry was there, without the renderer having to trace anything at load.
/// </summary>
public struct Cell
{
public float Height;
/// <summary>density (0-7) | normal.x (8-15) | normal.y (16-23) | entry index (24-31)</summary>
public uint Packed;
public readonly float Density => (Packed & 0xFF) / 255.0f;
/// <summary>Index into the definition's entry list. 0xFF means "pick one by weight".</summary>
public readonly int EntryIndex => (int)((Packed >> 24) & 0xFF);
public readonly Vector3 Normal
{
get
{
var x = ((Packed >> 8) & 0xFF) / 127.5f - 1.0f;
var y = ((Packed >> 16) & 0xFF) / 127.5f - 1.0f;
var z = MathF.Sqrt( Math.Clamp( 1.0f - x * x - y * y, 0.0f, 1.0f ) );
return new Vector3( x, y, z );
}
}
public static uint Pack( float density, Vector3 normal, int entryIndex )
{
var d = (uint)Math.Clamp( density * 255.0f + 0.5f, 0.0f, 255.0f );
var nx = (uint)Math.Clamp( (normal.x + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );
var ny = (uint)Math.Clamp( (normal.y + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );
var e = (uint)Math.Clamp( entryIndex, 0, 255 );
return d | (nx << 8) | (ny << 16) | (e << 24);
}
}
public readonly record struct ChunkCoord( int X, int Y );
private readonly Dictionary<ChunkCoord, Cell[]> _chunks = [];
/// <summary>Bumped on every mutation so the renderer knows to regenerate.</summary>
public int Revision { get; private set; }
public int ChunkCount => _chunks.Count;
public IReadOnlyDictionary<ChunkCoord, Cell[]> Chunks => _chunks;
public static ChunkCoord WorldToChunk( Vector3 world ) => new(
(int)MathF.Floor( world.x / ChunkSize ),
(int)MathF.Floor( world.y / ChunkSize ) );
public static Vector2 ChunkOrigin( ChunkCoord coord ) => new( coord.X * ChunkSize, coord.Y * ChunkSize );
public static Vector3 ChunkCenter( ChunkCoord coord, float height = 0.0f )
{
var origin = ChunkOrigin( coord );
return new Vector3( origin.x + ChunkSize * 0.5f, origin.y + ChunkSize * 0.5f, height );
}
private static int WorldToCell( float world ) => (int)MathF.Floor( world / CellSize );
private static int FloorDiv( int a, int b ) => a >= 0 ? a / b : ~(~a / b);
private static int Mod( int a, int b )
{
var r = a % b;
return r < 0 ? r + b : r;
}
/// <summary>
/// Writes a coverage sample, baking the surface height and normal alongside it. Density of zero
/// frees the sample.
/// </summary>
public void SetCell( float worldX, float worldY, float density, float height, Vector3 normal, int entryIndex )
{
var cellX = WorldToCell( worldX );
var cellY = WorldToCell( worldY );
var coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );
if ( !_chunks.TryGetValue( coord, out var cells ) )
{
if ( density <= 0.0f ) return;
cells = new Cell[CellsPerChunk];
_chunks[coord] = cells;
}
var index = Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution );
cells[index] = new Cell { Height = height, Packed = Cell.Pack( density, normal, entryIndex ) };
Revision++;
}
public Cell GetCell( float worldX, float worldY )
{
var cellX = WorldToCell( worldX );
var cellY = WorldToCell( worldY );
var coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );
if ( !_chunks.TryGetValue( coord, out var cells ) )
return default;
return cells[Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution )];
}
/// <summary>Reduces coverage in a radius, removing samples that reach zero.</summary>
public void Erase( Vector3 center, float radius, float strength )
{
var radiusSquared = radius * radius;
var minCellX = WorldToCell( center.x - radius );
var maxCellX = WorldToCell( center.x + radius );
var minCellY = WorldToCell( center.y - radius );
var maxCellY = WorldToCell( center.y + radius );
var changed = false;
for ( var cy = minCellY; cy <= maxCellY; cy++ )
{
for ( var cx = minCellX; cx <= maxCellX; cx++ )
{
var coord = new ChunkCoord( FloorDiv( cx, ChunkResolution ), FloorDiv( cy, ChunkResolution ) );
if ( !_chunks.TryGetValue( coord, out var cells ) )
continue;
var wx = (cx + 0.5f) * CellSize;
var wy = (cy + 0.5f) * CellSize;
var dx = wx - center.x;
var dy = wy - center.y;
if ( dx * dx + dy * dy > radiusSquared )
continue;
var index = Mod( cy, ChunkResolution ) * ChunkResolution + Mod( cx, ChunkResolution );
ref var cell = ref cells[index];
if ( (cell.Packed & 0xFF) == 0 )
continue;
var density = Math.Max( cell.Density - strength, 0.0f );
cell.Packed = density <= 0.0f
? 0u
: Cell.Pack( density, cell.Normal, cell.EntryIndex );
changed = true;
}
}
if ( !changed )
return;
PruneEmptyChunks();
Revision++;
}
public void ClearAll()
{
if ( _chunks.Count == 0 ) return;
_chunks.Clear();
Revision++;
}
private void PruneEmptyChunks()
{
List<ChunkCoord> empty = null;
foreach ( var (coord, cells) in _chunks )
{
var used = false;
for ( var i = 0; i < cells.Length; i++ )
{
if ( (cells[i].Packed & 0xFF) != 0 ) { used = true; break; }
}
if ( !used )
{
empty ??= [];
empty.Add( coord );
}
}
if ( empty is null ) return;
foreach ( var coord in empty )
_chunks.Remove( coord );
}
/// <summary>
/// Writes only the painted cells. Storing them densely cost 8KB per chunk however little of it
/// was painted, and a brush stroke across a landscape touches a lot of chunks.
///
/// Each painted cell costs 2 bytes more than it did dense (its index), so a chunk past about 80%
/// coverage is cheaper stored densely. Both layouts are written and each chunk says which it used.
/// </summary>
public override void Serialize( ref Writer writer )
{
writer.Stream.Write( _chunks.Count );
foreach ( var (coord, cells) in _chunks )
{
writer.Stream.Write( coord.X );
writer.Stream.Write( coord.Y );
var painted = 0;
for ( var i = 0; i < CellsPerChunk; i++ )
{
if ( (cells[i].Packed & 0xFF) != 0 ) painted++;
}
var sparse = painted * 10 < CellsPerChunk * 8;
writer.Stream.Write( sparse );
if ( !sparse )
{
for ( var i = 0; i < CellsPerChunk; i++ )
{
writer.Stream.Write( cells[i].Height );
writer.Stream.Write( cells[i].Packed );
}
continue;
}
writer.Stream.Write( painted );
for ( var i = 0; i < CellsPerChunk; i++ )
{
if ( (cells[i].Packed & 0xFF) == 0 )
continue;
writer.Stream.Write( (ushort)i );
writer.Stream.Write( cells[i].Height );
writer.Stream.Write( cells[i].Packed );
}
}
}
public override void Deserialize( ref Reader reader )
{
_chunks.Clear();
var chunkCount = reader.Stream.Read<int>();
for ( var c = 0; c < chunkCount; c++ )
{
var coord = new ChunkCoord( reader.Stream.Read<int>(), reader.Stream.Read<int>() );
var cells = new Cell[CellsPerChunk];
if ( reader.Stream.Read<bool>() )
{
var painted = reader.Stream.Read<int>();
for ( var p = 0; p < painted; p++ )
{
var index = reader.Stream.Read<ushort>();
var height = reader.Stream.Read<float>();
var packed = reader.Stream.Read<uint>();
if ( index < CellsPerChunk )
{
cells[index].Height = height;
cells[index].Packed = packed;
}
}
}
else
{
for ( var i = 0; i < CellsPerChunk; i++ )
{
cells[i].Height = reader.Stream.Read<float>();
cells[i].Packed = reader.Stream.Read<uint>();
}
}
_chunks[coord] = cells;
}
Revision++;
}
}
Game
library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Flora Tool" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "floratool" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "redsnail" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "redsnail.floratool" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "28" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-08-20T14:01:50.2662074Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.121.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.121.0")]
Debug: View Raw JSON Response
{
"TotalCount": 12,
"Files": [
{
"Ident": "redsnail.floratool",
"Path": "FloraRenderer.cs",
"FileName": "FloraRenderer.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 343456,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.FloraTool;\n\n/// <summary>\n/// Renders painted flora. Coverage is stored per chunk and instances are regenerated from it plus\n/// <see cref=\"Seed\"/>, so the scene file holds a density map rather than a transform per tree - the\n/// difference between a few megabytes and something a repository will refuse.\n///\n/// Chunks become scene objects only within the definition's stream radius. Scene objects rather than\n/// a hand-rolled instanced draw because they take part in every pass the engine runs: the depth\n/// prepass, the shadow cascades, and per-object LOD using the model's own compiled distances.\n/// Standard instancing still batches them into few draw calls.\n/// </summary>\n[Icon( \"park\" ), Group( \"Flora\" ), Title( \"Flora Renderer\" )]\npublic sealed partial class FloraRenderer : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer\n{\n\t/// <summary>A chunk's generated instances and the scene objects currently standing for them.</summary>\n\tprivate sealed class LiveChunk\n\t{\n\t\tpublic List<FloraGenerator.Instance> Instances = [];\n\t\tpublic List<SceneObject> SceneObjects = [];\n\n\t\t/// <summary>\n\t\t/// Whether this chunk is currently allowed to cast. Tracked so the flags are only touched\n\t\t/// when a chunk crosses the shadow boundary, rather than every object every frame.\n\t\t/// </summary>\n\t\tpublic bool ShadowsEnabled = true;\n\t}\n\n\t[Property, Group( \"General\" )]\n\tpublic FloraDefinition Definition { get; set; }\n\n\t/// <summary>\n\t/// Decides exactly where each instance lands within the painted coverage. Change it to reshuffle\n\t/// a whole forest without repainting; keep it fixed and the same trees stand in the same places\n\t/// every run, on every machine.\n\t/// </summary>\n\t[Property, Group( \"General\" )]\n\tpublic int Seed\n\t{\n\t\tget => field;\n\t\tset\n\t\t{\n\t\t\tif ( field == value ) return;\n\t\t\tfield = value;\n\t\t\tMarkDirty();\n\t\t}\n\t}\n\n\t/// <summary>Painted coverage. Serialized as a binary blob, not JSON.</summary>\n\t[Property, Hide]\n\tpublic FloraStorage Storage { get; set; } = new();\n\n\tprivate readonly Dictionary<FloraStorage.ChunkCoord, LiveChunk> _live = [];\n\tprivate readonly List<FloraStorage.ChunkCoord> _wantedChunks = [];\n\tprivate readonly List<FloraStorage.ChunkCoord> _staleChunks = [];\n\n\t// Reused across chunk builds so streaming doesn't allocate a fresh list per chunk.\n\tprivate readonly List<FloraGenerator.Instance> _scratchInstances = [];\n\n\tprivate int _builtRevision = -1;\n\tprivate Vector3 _lastStreamOrigin;\n\tprivate bool _hasStreamOrigin;\n\n\t/// <summary>\n\t/// Restreaming walks every painted chunk, so it only happens once the viewer has moved far enough\n\t/// for the answer to have changed. A fraction of a chunk keeps the boundary from thrashing.\n\t/// </summary>\n\tprivate const float StreamRefreshDistance = FloraStorage.ChunkSize * 0.25f;\n\n\tprotected override void OnEnabled()\n\t{\n\t\tStorage ??= new FloraStorage();\n\n\t\t// Scene objects were deleted on disable, so a matching revision would leave us thinking the\n\t\t// world is already built when nothing is in it.\n\t\t_builtRevision = -1;\n\t\t_hasStreamOrigin = false;\n\t}\n\n\tprotected override void OnDisabled()\n\t{\n\t\tReleaseAllChunks();\n\t\tReleaseCollision();\n\n\t\t_builtRevision = -1;\n\t\t_hasStreamOrigin = false;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tvar viewer = GetViewerPosition();\n\t\tif ( !viewer.HasValue )\n\t\t\treturn;\n\n\t\tUpdateStreaming( viewer.Value );\n\t\tUpdateCollision( viewer.Value );\n\t}\n\n\t/// <summary>\n\t/// What streaming follows. While editing that is the viewport camera, so flora appears around\n\t/// what you are looking at rather than wherever the game camera is parked.\n\t/// </summary>\n\tprivate Vector3? GetViewerPosition()\n\t{\n\t\tif ( Scene.IsEditor )\n\t\t{\n\t\t\tvar editorCamera = Application.Editor?.Camera;\n\t\t\tif ( editorCamera.IsValid() )\n\t\t\t\treturn editorCamera.WorldPosition;\n\t\t}\n\n\t\treturn Scene.Camera.IsValid() ? Scene.Camera.WorldPosition : null;\n\t}\n\n\tprivate void UpdateStreaming( Vector3 origin )\n\t{\n\t\tif ( Storage is null || !Definition.IsValid() )\n\t\t{\n\t\t\tReleaseAllChunks();\n\t\t\treturn;\n\t\t}\n\n\t\t// Painting or reseeding invalidates everything regardless of whether the viewer moved.\n\t\tvar dirty = _builtRevision != Storage.Revision;\n\n\t\tif ( !dirty && _hasStreamOrigin && origin.Distance( _lastStreamOrigin ) < StreamRefreshDistance )\n\t\t\treturn;\n\n\t\tif ( dirty )\n\t\t{\n\t\t\tReleaseAllChunks();\n\t\t\t_builtRevision = Storage.Revision;\n\t\t}\n\n\t\t_lastStreamOrigin = origin;\n\t\t_hasStreamOrigin = true;\n\n\t\tGatherWantedChunks( origin );\n\t\tSyncChunks( origin );\n\t}\n\n\tprivate void GatherWantedChunks( Vector3 origin )\n\t{\n\t\t_wantedChunks.Clear();\n\n\t\t// A chunk's near corner can be in range while its centre is not, hence the circumradius.\n\t\tvar radius = Definition.StreamRadius + FloraStorage.ChunkSize * 0.7072f;\n\t\tvar radiusSquared = radius * radius;\n\n\t\tforeach ( var (coord, _) in Storage.Chunks )\n\t\t{\n\t\t\tvar center = FloraStorage.ChunkCenter( coord );\n\n\t\t\tvar dx = center.x - origin.x;\n\t\t\tvar dy = center.y - origin.y;\n\n\t\t\tif ( dx * dx + dy * dy > radiusSquared )\n\t\t\t\tcontinue;\n\n\t\t\t_wantedChunks.Add( coord );\n\t\t}\n\t}\n\n\tprivate void SyncChunks( Vector3 origin )\n\t{\n\t\t_staleChunks.Clear();\n\n\t\tforeach ( var (coord, _) in _live )\n\t\t{\n\t\t\tif ( !_wantedChunks.Contains( coord ) )\n\t\t\t\t_staleChunks.Add( coord );\n\t\t}\n\n\t\tforeach ( var coord in _staleChunks )\n\t\t\tReleaseChunk( coord );\n\n\t\tforeach ( var coord in _wantedChunks )\n\t\t{\n\t\t\tif ( _live.ContainsKey( coord ) )\n\t\t\t\tcontinue;\n\n\t\t\tBuildChunk( coord, origin );\n\t\t}\n\n\t\tUpdateChunkShadows( origin );\n\t}\n\n\t/// <summary>\n\t/// Turns shadow casting off for chunks past the shadow distance. Evaluated per chunk rather than\n\t/// per instance, and only written when a chunk actually crosses the boundary, so a stationary\n\t/// camera costs nothing here.\n\t/// </summary>\n\tprivate void UpdateChunkShadows( Vector3 origin )\n\t{\n\t\tforeach ( var (coord, chunk) in _live )\n\t\t{\n\t\t\tvar wanted = ChunkCastsShadows( coord, origin );\n\t\t\tif ( wanted == chunk.ShadowsEnabled )\n\t\t\t\tcontinue;\n\n\t\t\tchunk.ShadowsEnabled = wanted;\n\t\t\tApplyChunkShadows( chunk );\n\t\t}\n\t}\n\n\tprivate bool ChunkCastsShadows( FloraStorage.ChunkCoord coord, Vector3 origin )\n\t{\n\t\tvar distance = Definition.ShadowDistance;\n\t\tif ( distance <= 0.0f )\n\t\t\treturn true;\n\n\t\t// Measured to the chunk's near edge via its circumradius, so a chunk is only cut off once all\n\t\t// of it is beyond the limit.\n\t\tvar limit = distance + FloraStorage.ChunkSize * 0.7072f;\n\n\t\tvar center = FloraStorage.ChunkCenter( coord );\n\t\tvar dx = center.x - origin.x;\n\t\tvar dy = center.y - origin.y;\n\n\t\treturn dx * dx + dy * dy <= limit * limit;\n\t}\n\n\t/// <summary>\n\t/// The per-entry CastShadows setting is the ceiling - distance can only ever take shadows away,\n\t/// never grant them to an entry the artist turned them off for.\n\t/// </summary>\n\tprivate void ApplyChunkShadows( LiveChunk chunk )\n\t{\n\t\tfor ( var i = 0; i < chunk.SceneObjects.Count && i < chunk.Instances.Count; i++ )\n\t\t{\n\t\t\tvar sceneObject = chunk.SceneObjects[i];\n\t\t\tif ( !sceneObject.IsValid() )\n\t\t\t\tcontinue;\n\n\t\t\tvar entry = Definition.GetEntry( chunk.Instances[i].EntryIndex );\n\t\t\tsceneObject.Flags.CastShadows = chunk.ShadowsEnabled && entry?.CastShadows is true;\n\t\t}\n\t}\n\n\tprivate void BuildChunk( FloraStorage.ChunkCoord coord, Vector3 origin )\n\t{\n\t\tif ( !Storage.Chunks.TryGetValue( coord, out var cells ) )\n\t\t\treturn;\n\n\t\tvar world = Scene.SceneWorld;\n\t\tif ( !world.IsValid() )\n\t\t\treturn;\n\n\t\tvar chunk = new LiveChunk();\n\t\tchunk.ShadowsEnabled = ChunkCastsShadows( coord, origin );\n\n\t\t_scratchInstances.Clear();\n\t\tFloraGenerator.GenerateChunk( coord, cells, Definition, Seed, _scratchInstances );\n\n\t\t// Instances and scene objects are kept strictly parallel - anything whose entry no longer\n\t\t// resolves is dropped from both. Skipping only the scene object would slide the two lists out\n\t\t// of step, and the shadow and collision paths index one by the other.\n\t\tfor ( var i = 0; i < _scratchInstances.Count; i++ )\n\t\t{\n\t\t\tvar instance = _scratchInstances[i];\n\n\t\t\tvar entry = Definition.GetEntry( instance.EntryIndex );\n\t\t\tif ( entry is null )\n\t\t\t\tcontinue;\n\n\t\t\tvar sceneObject = new SceneObject( world, entry.Model, instance.ToTransform() );\n\t\t\tsceneObject.Flags.CastShadows = chunk.ShadowsEnabled && entry.CastShadows;\n\n\t\t\tchunk.Instances.Add( instance );\n\t\t\tchunk.SceneObjects.Add( sceneObject );\n\t\t}\n\n\t\t_live[coord] = chunk;\n\t}\n\n\tprivate void ReleaseChunk( FloraStorage.ChunkCoord coord )\n\t{\n\t\tif ( !_live.Remove( coord, out var chunk ) )\n\t\t\treturn;\n\n\t\tforeach ( var sceneObject in chunk.SceneObjects )\n\t\t{\n\t\t\tif ( sceneObject.IsValid() )\n\t\t\t\tsceneObject.Delete();\n\t\t}\n\n\t\tchunk.SceneObjects.Clear();\n\t\tchunk.Instances.Clear();\n\t}\n\n\tprivate void ReleaseAllChunks()\n\t{\n\t\tforeach ( var (_, chunk) in _live )\n\t\t{\n\t\t\tforeach ( var sceneObject in chunk.SceneObjects )\n\t\t\t{\n\t\t\t\tif ( sceneObject.IsValid() )\n\t\t\t\t\tsceneObject.Delete();\n\t\t\t}\n\t\t}\n\n\t\t_live.Clear();\n\t\t_wantedChunks.Clear();\n\t\t_staleChunks.Clear();\n\t}\n\n\t/// <summary>\n\t/// Called by the editor tool after painting, so the next frame regenerates. Also fires when the\n\t/// seed changes.\n\t/// </summary>\n\tpublic void MarkDirty()\n\t{\n\t\t_builtRevision = -1;\n\t\t_hasStreamOrigin = false;\n\t}\n\n\t/// <summary>Total instances currently streamed in. Useful when tuning density and stream radius.</summary>\n\tpublic int LiveInstanceCount\n\t{\n\t\tget\n\t\t{\n\t\t\tvar count = 0;\n\t\t\tforeach ( var (_, chunk) in _live )\n\t\t\t\tcount += chunk.SceneObjects.Count;\n\t\t\treturn count;\n\t\t}\n\t}\n\n\tprotected override void DrawGizmos()\n\t{\n\t\tif ( !Gizmo.IsSelected || Storage is null || Storage.ChunkCount == 0 )\n\t\t\treturn;\n\n\t\tGizmo.Draw.Color = Color.Green.WithAlpha( 0.25f );\n\n\t\tforeach ( var (coord, _) in Storage.Chunks )\n\t\t{\n\t\t\tvar origin = FloraStorage.ChunkOrigin( coord );\n\n\t\t\tvar mins = WorldTransform.PointToLocal( new Vector3( origin.x, origin.y, 0 ) );\n\t\t\tvar maxs = WorldTransform.PointToLocal( new Vector3(\n\t\t\t\torigin.x + FloraStorage.ChunkSize, origin.y + FloraStorage.ChunkSize, 0 ) );\n\n\t\t\tGizmo.Draw.LineBBox( new BBox( mins, maxs ) );\n\t\t}\n\t}\n}\n"
},
{
"Ident": "redsnail.floratool",
"Path": "Code/FloraDefinition.cs",
"FileName": "FloraDefinition.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 343456,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.FloraTool;\n\n/// <summary>\n/// One kind of flora the brush can plant. Weight decides how often it comes up relative to the\n/// other entries in the definition.\n/// </summary>\npublic sealed class FloraEntry\n{\n\t[Property]\n\tpublic Model Model { get; set; }\n\n\t/// <summary>Relative chance of this entry being picked. Zero excludes it without deleting it.</summary>\n\t[Property, Range( 0, 10 )]\n\tpublic float Weight { get; set; } = 1.0f;\n\n\t[Property]\n\tpublic RangedFloat Scale { get; set; } = new( 0.85f, 1.25f );\n\n\t/// <summary>Random spin about the vertical axis, so repeated instances don't read as clones.</summary>\n\t[Property]\n\tpublic bool RandomYaw { get; set; } = true;\n\n\t/// <summary>\n\t/// Tilts the instance toward the surface normal. Right for rocks and bushes, usually wrong for\n\t/// trees - a trunk growing perpendicular to a hillside looks broken.\n\t/// </summary>\n\t[Property, Range( 0, 1 )]\n\tpublic float AlignToNormal { get; set; } = 0.0f;\n\n\t/// <summary>Random lean away from vertical, in degrees. A little goes a long way on trees.</summary>\n\t[Property, Range( 0, 45 )]\n\tpublic float RandomTilt { get; set; } = 0.0f;\n\n\t/// <summary>Sinks the instance into the ground, hiding the seam where the base meets the surface.</summary>\n\t[Property, Range( 0, 64 )]\n\tpublic float SinkDepth { get; set; } = 0.0f;\n\n\t/// <summary>\n\t/// Gives this entry real collision. Colliders are only created near the player, so this is about\n\t/// whether the flora is solid at all - not about paying for every painted instance at once.\n\t/// </summary>\n\t[Property, Group( \"Physics\" )]\n\tpublic bool EnablePhysics { get; set; } = true;\n\n\t[Property, Group( \"Rendering\" )]\n\tpublic bool CastShadows { get; set; } = true;\n\n\tpublic bool HasModel => Model is not null && !string.IsNullOrEmpty( Model.ResourcePath );\n}\n\n/// <summary>\n/// A palette of flora plus the rules used when painting it. Shared by every\n/// <see cref=\"FloraRenderer\"/> that references it, so a whole world can be retuned from one asset.\n/// </summary>\n[AssetType( Name = \"Flora Definition\", Extension = \"floradef\", Category = \"Flora\" )]\npublic sealed class FloraDefinition : GameResource\n{\n\t[Property]\n\tpublic List<FloraEntry> Entries { get; set; } = [];\n\n\t/// <summary>\n\t/// Instances a fully painted cell can hold. Coverage scales this, so it sets the ceiling on how\n\t/// tightly flora can pack - raise it for undergrowth, leave it low for trees.\n\t/// </summary>\n\t[Property, Group( \"Painting\" ), Range( 1, 16 )]\n\tpublic int MaxPerCell { get; set; } = 2;\n\n\t/// <summary>Minimum ground normal Z. Steeper than this and nothing plants, so cliffs stay bare.</summary>\n\t[Property, Group( \"Painting\" ), Range( 0, 1 )]\n\tpublic float SlopeLimit { get; set; } = 0.6f;\n\n\t/// <summary>\n\t/// Radius around the viewer within which chunks are turned into scene objects. Chunks beyond it\n\t/// keep their painted coverage but cost nothing to render.\n\t/// </summary>\n\t[Property, Group( \"Streaming\" ), Range( 2000, 100000 )]\n\tpublic float StreamRadius { get; set; } = 25000.0f;\n\n\t/// <summary>\n\t/// Distance past which flora stops casting shadows. Shadow cascades ignore the view frustum, so\n\t/// distant trees are rendered into them whichever way the camera faces - dropping them is one of\n\t/// the few savings that applies even when you are looking away.\n\t///\n\t/// Set it too low and you will see shadows wink out as chunks cross the boundary, most obviously\n\t/// under a low sun where far geometry casts long shadows into view. Zero disables the cutoff.\n\t/// </summary>\n\t[Property, Group( \"Streaming\" ), Range( 0, 50000 )]\n\tpublic float ShadowDistance { get; set; } = 10000.0f;\n\n\t/// <summary>\n\t/// Radius around the viewer within which entries flagged <see cref=\"FloraEntry.EnablePhysics\"/>\n\t/// get real colliders. Keep it just past where the player can reach.\n\t/// </summary>\n\t[Property, Group( \"Physics\" ), Range( 256, 20000 )]\n\tpublic float CollisionRadius { get; set; } = 4000.0f;\n\n\t/// <summary>\n\t/// The entry at an index, or null when the index no longer resolves - entries can be removed\n\t/// after coverage has already been painted naming them.\n\t/// </summary>\n\tpublic FloraEntry GetEntry( int index )\n\t{\n\t\tif ( Entries is null || index < 0 || index >= Entries.Count )\n\t\t\treturn null;\n\n\t\tvar entry = Entries[index];\n\t\treturn entry?.HasModel is true ? entry : null;\n\t}\n}\n"
},
{
"Ident": "redsnail.floratool",
"Path": "Code/FloraStorage.cs",
"FileName": "FloraStorage.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 343456,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.FloraTool;\n\n/// <summary>\n/// Painted flora coverage, stored as a sparse chunked grid of density samples rather than one\n/// transform per tree. Instances are regenerated from this plus a seed, so a forest of a hundred\n/// thousand trees costs a few megabytes instead of tens - which matters because the scene sidecar\n/// has to survive being committed to a repository.\n///\n/// The trade is that positions are derived, not authored: painting decides where flora *can* grow\n/// and how densely, and the seed decides exactly where each trunk lands.\n/// </summary>\npublic sealed class FloraStorage : BlobData\n{\n\tpublic override int Version => 1;\n\n\t/// <summary>Cells along one edge of a chunk.</summary>\n\tpublic const int ChunkResolution = 32;\n\n\t/// <summary>\n\t/// World size of one density cell. Roughly a tree's footprint - each cell holds at most a\n\t/// handful of instances, so this is what bounds how tightly flora can pack.\n\t/// Changing it invalidates every painted scene, so it is a constant rather than a setting.\n\t/// </summary>\n\tpublic const float CellSize = 256.0f;\n\n\tpublic const float ChunkSize = ChunkResolution * CellSize;\n\n\tpublic const int CellsPerChunk = ChunkResolution * ChunkResolution;\n\n\t/// <summary>\n\t/// One coverage sample. Height and normal are baked at paint time so flora sits on whatever\n\t/// geometry was there, without the renderer having to trace anything at load.\n\t/// </summary>\n\tpublic struct Cell\n\t{\n\t\tpublic float Height;\n\n\t\t/// <summary>density (0-7) | normal.x (8-15) | normal.y (16-23) | entry index (24-31)</summary>\n\t\tpublic uint Packed;\n\n\t\tpublic readonly float Density => (Packed & 0xFF) / 255.0f;\n\n\t\t/// <summary>Index into the definition's entry list. 0xFF means \"pick one by weight\".</summary>\n\t\tpublic readonly int EntryIndex => (int)((Packed >> 24) & 0xFF);\n\n\t\tpublic readonly Vector3 Normal\n\t\t{\n\t\t\tget\n\t\t\t{\n\t\t\t\tvar x = ((Packed >> 8) & 0xFF) / 127.5f - 1.0f;\n\t\t\t\tvar y = ((Packed >> 16) & 0xFF) / 127.5f - 1.0f;\n\t\t\t\tvar z = MathF.Sqrt( Math.Clamp( 1.0f - x * x - y * y, 0.0f, 1.0f ) );\n\t\t\t\treturn new Vector3( x, y, z );\n\t\t\t}\n\t\t}\n\n\t\tpublic static uint Pack( float density, Vector3 normal, int entryIndex )\n\t\t{\n\t\t\tvar d = (uint)Math.Clamp( density * 255.0f + 0.5f, 0.0f, 255.0f );\n\t\t\tvar nx = (uint)Math.Clamp( (normal.x + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );\n\t\t\tvar ny = (uint)Math.Clamp( (normal.y + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );\n\t\t\tvar e = (uint)Math.Clamp( entryIndex, 0, 255 );\n\n\t\t\treturn d | (nx << 8) | (ny << 16) | (e << 24);\n\t\t}\n\t}\n\n\tpublic readonly record struct ChunkCoord( int X, int Y );\n\n\tprivate readonly Dictionary<ChunkCoord, Cell[]> _chunks = [];\n\n\t/// <summary>Bumped on every mutation so the renderer knows to regenerate.</summary>\n\tpublic int Revision { get; private set; }\n\n\tpublic int ChunkCount => _chunks.Count;\n\n\tpublic IReadOnlyDictionary<ChunkCoord, Cell[]> Chunks => _chunks;\n\n\tpublic static ChunkCoord WorldToChunk( Vector3 world ) => new(\n\t\t(int)MathF.Floor( world.x / ChunkSize ),\n\t\t(int)MathF.Floor( world.y / ChunkSize ) );\n\n\tpublic static Vector2 ChunkOrigin( ChunkCoord coord ) => new( coord.X * ChunkSize, coord.Y * ChunkSize );\n\n\tpublic static Vector3 ChunkCenter( ChunkCoord coord, float height = 0.0f )\n\t{\n\t\tvar origin = ChunkOrigin( coord );\n\t\treturn new Vector3( origin.x + ChunkSize * 0.5f, origin.y + ChunkSize * 0.5f, height );\n\t}\n\n\tprivate static int WorldToCell( float world ) => (int)MathF.Floor( world / CellSize );\n\n\tprivate static int FloorDiv( int a, int b ) => a >= 0 ? a / b : ~(~a / b);\n\n\tprivate static int Mod( int a, int b )\n\t{\n\t\tvar r = a % b;\n\t\treturn r < 0 ? r + b : r;\n\t}\n\n\t/// <summary>\n\t/// Writes a coverage sample, baking the surface height and normal alongside it. Density of zero\n\t/// frees the sample.\n\t/// </summary>\n\tpublic void SetCell( float worldX, float worldY, float density, float height, Vector3 normal, int entryIndex )\n\t{\n\t\tvar cellX = WorldToCell( worldX );\n\t\tvar cellY = WorldToCell( worldY );\n\t\tvar coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );\n\n\t\tif ( !_chunks.TryGetValue( coord, out var cells ) )\n\t\t{\n\t\t\tif ( density <= 0.0f ) return;\n\n\t\t\tcells = new Cell[CellsPerChunk];\n\t\t\t_chunks[coord] = cells;\n\t\t}\n\n\t\tvar index = Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution );\n\t\tcells[index] = new Cell { Height = height, Packed = Cell.Pack( density, normal, entryIndex ) };\n\n\t\tRevision++;\n\t}\n\n\tpublic Cell GetCell( float worldX, float worldY )\n\t{\n\t\tvar cellX = WorldToCell( worldX );\n\t\tvar cellY = WorldToCell( worldY );\n\t\tvar coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );\n\n\t\tif ( !_chunks.TryGetValue( coord, out var cells ) )\n\t\t\treturn default;\n\n\t\treturn cells[Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution )];\n\t}\n\n\t/// <summary>Reduces coverage in a radius, removing samples that reach zero.</summary>\n\tpublic void Erase( Vector3 center, float radius, float strength )\n\t{\n\t\tvar radiusSquared = radius * radius;\n\n\t\tvar minCellX = WorldToCell( center.x - radius );\n\t\tvar maxCellX = WorldToCell( center.x + radius );\n\t\tvar minCellY = WorldToCell( center.y - radius );\n\t\tvar maxCellY = WorldToCell( center.y + radius );\n\n\t\tvar changed = false;\n\n\t\tfor ( var cy = minCellY; cy <= maxCellY; cy++ )\n\t\t{\n\t\t\tfor ( var cx = minCellX; cx <= maxCellX; cx++ )\n\t\t\t{\n\t\t\t\tvar coord = new ChunkCoord( FloorDiv( cx, ChunkResolution ), FloorDiv( cy, ChunkResolution ) );\n\t\t\t\tif ( !_chunks.TryGetValue( coord, out var cells ) )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar wx = (cx + 0.5f) * CellSize;\n\t\t\t\tvar wy = (cy + 0.5f) * CellSize;\n\t\t\t\tvar dx = wx - center.x;\n\t\t\t\tvar dy = wy - center.y;\n\n\t\t\t\tif ( dx * dx + dy * dy > radiusSquared )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar index = Mod( cy, ChunkResolution ) * ChunkResolution + Mod( cx, ChunkResolution );\n\t\t\t\tref var cell = ref cells[index];\n\n\t\t\t\tif ( (cell.Packed & 0xFF) == 0 )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar density = Math.Max( cell.Density - strength, 0.0f );\n\t\t\t\tcell.Packed = density <= 0.0f\n\t\t\t\t\t? 0u\n\t\t\t\t\t: Cell.Pack( density, cell.Normal, cell.EntryIndex );\n\n\t\t\t\tchanged = true;\n\t\t\t}\n\t\t}\n\n\t\tif ( !changed )\n\t\t\treturn;\n\n\t\tPruneEmptyChunks();\n\t\tRevision++;\n\t}\n\n\tpublic void ClearAll()\n\t{\n\t\tif ( _chunks.Count == 0 ) return;\n\n\t\t_chunks.Clear();\n\t\tRevision++;\n\t}\n\n\tprivate void PruneEmptyChunks()\n\t{\n\t\tList<ChunkCoord> empty = null;\n\n\t\tforeach ( var (coord, cells) in _chunks )\n\t\t{\n\t\t\tvar used = false;\n\t\t\tfor ( var i = 0; i < cells.Length; i++ )\n\t\t\t{\n\t\t\t\tif ( (cells[i].Packed & 0xFF) != 0 ) { used = true; break; }\n\t\t\t}\n\n\t\t\tif ( !used )\n\t\t\t{\n\t\t\t\tempty ??= [];\n\t\t\t\tempty.Add( coord );\n\t\t\t}\n\t\t}\n\n\t\tif ( empty is null ) return;\n\n\t\tforeach ( var coord in empty )\n\t\t\t_chunks.Remove( coord );\n\t}\n\n\t/// <summary>\n\t/// Writes only the painted cells. Storing them densely cost 8KB per chunk however little of it\n\t/// was painted, and a brush stroke across a landscape touches a lot of chunks.\n\t///\n\t/// Each painted cell costs 2 bytes more than it did dense (its index), so a chunk past about 80%\n\t/// coverage is cheaper stored densely. Both layouts are written and each chunk says which it used.\n\t/// </summary>\n\tpublic override void Serialize( ref Writer writer )\n\t{\n\t\twriter.Stream.Write( _chunks.Count );\n\n\t\tforeach ( var (coord, cells) in _chunks )\n\t\t{\n\t\t\twriter.Stream.Write( coord.X );\n\t\t\twriter.Stream.Write( coord.Y );\n\n\t\t\tvar painted = 0;\n\t\t\tfor ( var i = 0; i < CellsPerChunk; i++ )\n\t\t\t{\n\t\t\t\tif ( (cells[i].Packed & 0xFF) != 0 ) painted++;\n\t\t\t}\n\n\t\t\tvar sparse = painted * 10 < CellsPerChunk * 8;\n\t\t\twriter.Stream.Write( sparse );\n\n\t\t\tif ( !sparse )\n\t\t\t{\n\t\t\t\tfor ( var i = 0; i < CellsPerChunk; i++ )\n\t\t\t\t{\n\t\t\t\t\twriter.Stream.Write( cells[i].Height );\n\t\t\t\t\twriter.Stream.Write( cells[i].Packed );\n\t\t\t\t}\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\twriter.Stream.Write( painted );\n\n\t\t\tfor ( var i = 0; i < CellsPerChunk; i++ )\n\t\t\t{\n\t\t\t\tif ( (cells[i].Packed & 0xFF) == 0 )\n\t\t\t\t\tcontinue;\n\n\t\t\t\twriter.Stream.Write( (ushort)i );\n\t\t\t\twriter.Stream.Write( cells[i].Height );\n\t\t\t\twriter.Stream.Write( cells[i].Packed );\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic override void Deserialize( ref Reader reader )\n\t{\n\t\t_chunks.Clear();\n\n\t\tvar chunkCount = reader.Stream.Read<int>();\n\n\t\tfor ( var c = 0; c < chunkCount; c++ )\n\t\t{\n\t\t\tvar coord = new ChunkCoord( reader.Stream.Read<int>(), reader.Stream.Read<int>() );\n\t\t\tvar cells = new Cell[CellsPerChunk];\n\n\t\t\tif ( reader.Stream.Read<bool>() )\n\t\t\t{\n\t\t\t\tvar painted = reader.Stream.Read<int>();\n\n\t\t\t\tfor ( var p = 0; p < painted; p++ )\n\t\t\t\t{\n\t\t\t\t\tvar index = reader.Stream.Read<ushort>();\n\t\t\t\t\tvar height = reader.Stream.Read<float>();\n\t\t\t\t\tvar packed = reader.Stream.Read<uint>();\n\n\t\t\t\t\tif ( index < CellsPerChunk )\n\t\t\t\t\t{\n\t\t\t\t\t\tcells[index].Height = height;\n\t\t\t\t\t\tcells[index].Packed = packed;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tfor ( var i = 0; i < CellsPerChunk; i++ )\n\t\t\t\t{\n\t\t\t\t\tcells[i].Height = reader.Stream.Read<float>();\n\t\t\t\t\tcells[i].Packed = reader.Stream.Read<uint>();\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t_chunks[coord] = cells;\n\t\t}\n\n\t\tRevision++;\n\t}\n}\n"
},
{
"Ident": "redsnail.floratool",
"Path": "Code/FloraRenderer.Collision.cs",
"FileName": "FloraRenderer.Collision.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 343456,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.FloraTool;\n\n/// <summary>\n/// Collision for painted flora. Instances only exist as scene objects, so nothing is solid until a\n/// collider is made for it - and those are made only for instances near the viewer and recycled as\n/// it moves, keeping physics cost tied to what is reachable rather than to the whole forest.\n/// </summary>\npublic sealed partial class FloraRenderer\n{\n\tprivate readonly record struct CollisionKey( FloraStorage.ChunkCoord Chunk, int Index );\n\n\tprivate readonly Dictionary<CollisionKey, GameObject> _colliders = [];\n\n\t// A set rather than a list: SyncColliders tests every live collider against it, so a linear scan\n\t// there would be quadratic once a few hundred are in range.\n\tprivate readonly HashSet<CollisionKey> _wantedColliders = [];\n\tprivate readonly List<CollisionKey> _staleColliders = [];\n\n\tprivate GameObject _collisionRoot;\n\tprivate Vector3 _lastCollisionOrigin;\n\tprivate bool _hasCollisionOrigin;\n\tprivate int _collisionRevision = -1;\n\n\t/// <summary>\n\t/// Rebuilding walks every streamed instance, so it only happens once the viewer has moved far\n\t/// enough for the answer to have changed.\n\t/// </summary>\n\tprivate const float CollisionRefreshDistance = 256.0f;\n\n\tprivate void UpdateCollision( Vector3 origin )\n\t{\n\t\tif ( !Definition.IsValid() || Definition.CollisionRadius <= 0.0f )\n\t\t{\n\t\t\tReleaseCollision();\n\t\t\treturn;\n\t\t}\n\n\t\tvar storageChanged = Storage is null || _collisionRevision != Storage.Revision;\n\n\t\tif ( !storageChanged && _hasCollisionOrigin &&\n\t\t\t origin.Distance( _lastCollisionOrigin ) < CollisionRefreshDistance )\n\t\t\treturn;\n\n\t\t_collisionRevision = Storage?.Revision ?? -1;\n\t\t_lastCollisionOrigin = origin;\n\t\t_hasCollisionOrigin = true;\n\n\t\tGatherWantedColliders( origin );\n\t\tSyncColliders();\n\t}\n\n\t/// <summary>\n\t/// Only streamed chunks are considered. Collision radius should sit well inside the stream radius\n\t/// anyway, so anything outside it has no business being solid.\n\t/// </summary>\n\tprivate void GatherWantedColliders( Vector3 origin )\n\t{\n\t\t_wantedColliders.Clear();\n\n\t\tvar radiusSquared = Definition.CollisionRadius * Definition.CollisionRadius;\n\n\t\tforeach ( var (coord, chunk) in _live )\n\t\t{\n\t\t\tfor ( var i = 0; i < chunk.Instances.Count; i++ )\n\t\t\t{\n\t\t\t\tvar instance = chunk.Instances[i];\n\n\t\t\t\tif ( instance.Position.DistanceSquared( origin ) > radiusSquared )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar entry = Definition.GetEntry( instance.EntryIndex );\n\t\t\t\tif ( entry?.EnablePhysics is not true )\n\t\t\t\t\tcontinue;\n\n\t\t\t\t_wantedColliders.Add( new CollisionKey( coord, i ) );\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void SyncColliders()\n\t{\n\t\t// Drop what fell out of range first, so those objects are free to be reused this same frame.\n\t\t_staleColliders.Clear();\n\n\t\tforeach ( var (key, gameObject) in _colliders )\n\t\t{\n\t\t\tif ( gameObject.IsValid() && _wantedColliders.Contains( key ) )\n\t\t\t\tcontinue;\n\n\t\t\t_staleColliders.Add( key );\n\t\t}\n\n\t\tforeach ( var key in _staleColliders )\n\t\t{\n\t\t\tif ( _colliders.Remove( key, out var gameObject ) && gameObject.IsValid() )\n\t\t\t\tgameObject.Destroy();\n\t\t}\n\n\t\tforeach ( var key in _wantedColliders )\n\t\t{\n\t\t\tif ( _colliders.ContainsKey( key ) )\n\t\t\t\tcontinue;\n\n\t\t\tvar gameObject = CreateCollider( key );\n\t\t\tif ( gameObject.IsValid() )\n\t\t\t\t_colliders[key] = gameObject;\n\t\t}\n\t}\n\n\tprivate GameObject CreateCollider( CollisionKey key )\n\t{\n\t\tif ( !_live.TryGetValue( key.Chunk, out var chunk ) )\n\t\t\treturn null;\n\n\t\tif ( key.Index < 0 || key.Index >= chunk.Instances.Count )\n\t\t\treturn null;\n\n\t\tvar instance = chunk.Instances[key.Index];\n\n\t\tvar entry = Definition.GetEntry( instance.EntryIndex );\n\t\tif ( entry is null )\n\t\t\treturn null;\n\n\t\tEnsureCollisionRoot();\n\n\t\tvar gameObject = new GameObject( true, \"FloraCollider\" )\n\t\t{\n\t\t\tParent = _collisionRoot,\n\t\t\tWorldTransform = instance.ToTransform(),\n\t\t};\n\n\t\t// Not saved with the scene and not shown in the hierarchy - these are transient physics\n\t\t// proxies for geometry that is regenerated from the seed anyway.\n\t\tgameObject.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;\n\n\t\tvar collider = gameObject.Components.Create<ModelCollider>();\n\t\tcollider.Model = entry.Model;\n\t\tcollider.Static = true;\n\n\t\treturn gameObject;\n\t}\n\n\tprivate void EnsureCollisionRoot()\n\t{\n\t\tif ( _collisionRoot.IsValid() )\n\t\t\treturn;\n\n\t\t_collisionRoot = new GameObject( true, \"Flora Colliders\" ) { Parent = GameObject };\n\t\t_collisionRoot.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;\n\t}\n\n\tprivate void ReleaseCollision()\n\t{\n\t\tforeach ( var (_, gameObject) in _colliders )\n\t\t{\n\t\t\tif ( gameObject.IsValid() )\n\t\t\t\tgameObject.Destroy();\n\t\t}\n\n\t\t_colliders.Clear();\n\t\t_wantedColliders.Clear();\n\t\t_staleColliders.Clear();\n\n\t\tif ( _collisionRoot.IsValid() )\n\t\t\t_collisionRoot.Destroy();\n\n\t\t_collisionRoot = null;\n\t\t_hasCollisionOrigin = false;\n\t\t_collisionRevision = -1;\n\t}\n}\n"
},
{
"Ident": "redsnail.floratool",
"Path": "FloraGenerator.cs",
"FileName": "FloraGenerator.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 343456,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.FloraTool;\n\n/// <summary>\n/// Turns painted coverage into concrete instances. Everything here is a pure function of the chunk\n/// coordinate, the cell contents and the seed - no state, no RNG object - so a chunk regenerates\n/// identically every run, on every machine, however many times it is streamed in and out.\n/// </summary>\npublic static class FloraGenerator\n{\n\tpublic readonly record struct Instance( int EntryIndex, Vector3 Position, Rotation Rotation, float Scale )\n\t{\n\t\tpublic readonly Transform ToTransform() => new( Position, Rotation, Scale );\n\t}\n\n\t/// <summary>\n\t/// Integer avalanche hash. Deterministic across runs and platforms, which the framework RNG is\n\t/// not guaranteed to be, and cheap enough to call several times per instance.\n\t/// </summary>\n\tprivate static uint Hash( uint x )\n\t{\n\t\tx ^= x >> 16;\n\t\tx *= 0x7feb352du;\n\t\tx ^= x >> 15;\n\t\tx *= 0x846ca68bu;\n\t\tx ^= x >> 16;\n\t\treturn x;\n\t}\n\n\tprivate static float HashFloat( uint x ) => Hash( x ) * (1.0f / 4294967296.0f);\n\n\t/// <summary>\n\t/// Generates every instance for one chunk, appending into <paramref name=\"results\"/>.\n\t/// </summary>\n\tpublic static void GenerateChunk( FloraStorage.ChunkCoord coord, FloraStorage.Cell[] cells,\n\t\tFloraDefinition definition, int seed, List<Instance> results )\n\t{\n\t\tif ( cells is null || definition is null )\n\t\t\treturn;\n\n\t\tvar origin = FloraStorage.ChunkOrigin( coord );\n\t\tvar maxPerCell = Math.Max( definition.MaxPerCell, 1 );\n\n\t\t// Mixing the chunk coordinate into the seed keeps neighbouring chunks from sharing a\n\t\t// sequence, which would otherwise show up as a visible repeating pattern across the world.\n\t\tvar chunkSeed = Hash( (uint)seed\n\t\t\t^ Hash( (uint)coord.X * 73856093u )\n\t\t\t^ Hash( (uint)coord.Y * 19349663u ) );\n\n\t\tfor ( var cellIndex = 0; cellIndex < cells.Length; cellIndex++ )\n\t\t{\n\t\t\tvar cell = cells[cellIndex];\n\n\t\t\tvar density = cell.Density;\n\t\t\tif ( density <= 0.0f )\n\t\t\t\tcontinue;\n\n\t\t\tif ( cell.Normal.z < definition.SlopeLimit )\n\t\t\t\tcontinue;\n\n\t\t\tvar cellSeed = Hash( chunkSeed ^ Hash( (uint)cellIndex * 0x9e3779b9u ) );\n\n\t\t\tvar cx = cellIndex % FloraStorage.ChunkResolution;\n\t\t\tvar cy = cellIndex / FloraStorage.ChunkResolution;\n\n\t\t\tvar cellMinX = origin.x + cx * FloraStorage.CellSize;\n\t\t\tvar cellMinY = origin.y + cy * FloraStorage.CellSize;\n\n\t\t\t// Fractional counts are resolved by a hash rather than rounding, so density reads as a\n\t\t\t// smooth thinning across a field instead of stepping between whole numbers per cell.\n\t\t\tvar exact = density * maxPerCell;\n\t\t\tvar count = (int)exact;\n\t\t\tif ( HashFloat( cellSeed ^ 0x1b56c4e9u ) < exact - count )\n\t\t\t\tcount++;\n\n\t\t\tfor ( var i = 0; i < count; i++ )\n\t\t\t{\n\t\t\t\tvar s = Hash( cellSeed + (uint)i * 0x85ebca6bu );\n\n\t\t\t\tvar entry = ResolveEntry( definition, cell.EntryIndex, s );\n\t\t\t\tif ( entry.Index < 0 )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tresults.Add( BuildInstance( entry.Index, entry.Entry, cell, s, cellMinX, cellMinY ) );\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// A cell either names its entry - painted deliberately with one species selected - or defers to\n\t/// the definition's weights.\n\t/// </summary>\n\tprivate static (int Index, FloraEntry Entry) ResolveEntry( FloraDefinition definition, int cellEntryIndex, uint seed )\n\t{\n\t\tvar entries = definition.Entries;\n\t\tif ( entries is null || entries.Count == 0 )\n\t\t\treturn (-1, null);\n\n\t\tif ( cellEntryIndex < entries.Count )\n\t\t{\n\t\t\tvar named = entries[cellEntryIndex];\n\t\t\treturn named?.HasModel is true ? (cellEntryIndex, named) : (-1, null);\n\t\t}\n\n\t\tvar total = 0.0f;\n\t\tfor ( var i = 0; i < entries.Count; i++ )\n\t\t{\n\t\t\tif ( entries[i]?.HasModel is true && entries[i].Weight > 0.0f )\n\t\t\t\ttotal += entries[i].Weight;\n\t\t}\n\n\t\tif ( total <= 0.0f )\n\t\t\treturn (-1, null);\n\n\t\tvar pick = HashFloat( seed ^ 0x3c6ef372u ) * total;\n\n\t\tfor ( var i = 0; i < entries.Count; i++ )\n\t\t{\n\t\t\tvar entry = entries[i];\n\t\t\tif ( entry?.HasModel is not true || entry.Weight <= 0.0f )\n\t\t\t\tcontinue;\n\n\t\t\tpick -= entry.Weight;\n\t\t\tif ( pick <= 0.0f )\n\t\t\t\treturn (i, entry);\n\t\t}\n\n\t\treturn (-1, null);\n\t}\n\n\tprivate static Instance BuildInstance( int entryIndex, FloraEntry entry, FloraStorage.Cell cell,\n\t\tuint seed, float cellMinX, float cellMinY )\n\t{\n\t\tvar jitterX = HashFloat( seed ^ 0x68bc21ebu );\n\t\tvar jitterY = HashFloat( seed ^ 0x02e5be93u );\n\n\t\tvar x = cellMinX + jitterX * FloraStorage.CellSize;\n\t\tvar y = cellMinY + jitterY * FloraStorage.CellSize;\n\n\t\tvar normal = cell.Normal;\n\n\t\t// The baked height is the cell centre's, so a slope needs the offset carried across to the\n\t\t// jittered position or trunks float on the uphill side and sink on the downhill one.\n\t\tvar offsetX = x - (cellMinX + FloraStorage.CellSize * 0.5f);\n\t\tvar offsetY = y - (cellMinY + FloraStorage.CellSize * 0.5f);\n\t\tvar z = cell.Height - (normal.x * offsetX + normal.y * offsetY) / MathF.Max( normal.z, 0.1f );\n\n\t\tvar position = new Vector3( x, y, z );\n\t\tif ( entry.SinkDepth > 0.0f )\n\t\t\tposition -= normal * entry.SinkDepth;\n\n\t\tvar rotation = entry.RandomYaw\n\t\t\t? Rotation.FromYaw( HashFloat( seed ^ 0x7f4a7c15u ) * 360.0f )\n\t\t\t: Rotation.Identity;\n\n\t\tif ( entry.AlignToNormal > 0.0f )\n\t\t{\n\t\t\tvar aligned = Rotation.LookAt( normal ) * Rotation.FromPitch( 90.0f );\n\t\t\trotation = Rotation.Slerp( rotation, aligned * rotation, entry.AlignToNormal );\n\t\t}\n\n\t\tif ( entry.RandomTilt > 0.0f )\n\t\t{\n\t\t\tvar tiltAngle = HashFloat( seed ^ 0x165667b1u ) * entry.RandomTilt;\n\t\t\tvar tiltDirection = HashFloat( seed ^ 0x27d4eb2fu ) * 360.0f;\n\t\t\trotation *= Rotation.FromAxis( Rotation.FromYaw( tiltDirection ).Forward, tiltAngle );\n\t\t}\n\n\t\tvar scale = MathX.Lerp( entry.Scale.Min, entry.Scale.Max, HashFloat( seed ^ 0xd3a2646cu ) );\n\n\t\treturn new Instance( entryIndex, position, rotation, scale );\n\t}\n}\n"
},
{
"Ident": "redsnail.floratool",
"Path": "Editor/FloraTool.cs",
"FileName": "FloraTool.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 343456,
"Code": "using System;\nusing System.Linq;\nusing Editor;\nusing Editor.TerrainEditor;\nusing Sandbox;\n\nnamespace RedSnail.FloraTool.Editor;\n\n/// <summary>\n/// Paints flora onto any surface. Each stroke scatters entries from the target renderer's\n/// definition, honouring its spacing and slope rules, and bakes the resulting transform into the\n/// renderer's storage. Hold Ctrl to erase.\n/// </summary>\n[EditorTool( \"flora\" )]\n[Title( \"Flora\" )]\n[Icon( \"park\" )]\npublic sealed class FloraPaintTool : EditorTool\n{\n\tpublic BrushSettings BrushSettings { get; private set; } = new();\n\n\tprivate FloraRenderer _target;\n\tprivate bool _erasing;\n\tprivate bool _dragging;\n\tprivate bool _painted;\n\tprivate Vector3 _lastPaintPosition;\n\n\tprivate ComboBox _entryDropdown;\n\n\t/// <summary>Index into the definition's entries, or 255 for \"mix by weight\".</summary>\n\tprivate int _entryIndex = MixedEntryIndex;\n\n\tprivate const int MixedEntryIndex = 255;\n\n\t// The brush has to travel a fraction of its own radius before depositing again, or holding the\n\t// mouse still would keep hammering the same spot with traces.\n\tprivate float PaintStepDistance => BrushSettings.Size * 0.35f;\n\n\tpublic FloraPaintTool()\n\t{\n\t\tRebuildSidebarOnSelectionChange = false;\n\t}\n\n\tpublic override Widget CreateToolSidebar()\n\t{\n\t\tvar sidebar = new ToolSidebarWidget();\n\t\tsidebar.AddTitle( \"Flora Brush\", \"brush\" );\n\t\tsidebar.MinimumWidth = 300;\n\n\t\t{\n\t\t\tvar group = sidebar.AddGroup( \"Brush\" );\n\t\t\tvar so = BrushSettings.GetSerialized();\n\t\t\tgroup.Add( ControlSheet.CreateRow( so.GetProperty( nameof( BrushSettings.Size ) ) ) );\n\t\t\tgroup.Add( ControlSheet.CreateRow( so.GetProperty( nameof( BrushSettings.Opacity ) ) ) );\n\t\t}\n\n\t\t{\n\t\t\t// Coverage names the entry it was painted with, so an artist can lay down pines here and\n\t\t\t// oaks there rather than getting one weighted mix everywhere.\n\t\t\tvar group = sidebar.AddGroup( \"Entry\" );\n\n\t\t\t_entryDropdown = new ComboBox( sidebar );\n\t\t\t_entryDropdown.ToolTip = \"Which flora entry this stroke paints. Mixed uses the definition's weights.\";\n\t\t\tRebuildEntryOptions();\n\n\t\t\tgroup.Add( _entryDropdown );\n\t\t}\n\n\t\t{\n\t\t\tvar group = sidebar.AddGroup( \"Actions\" );\n\n\t\t\tvar clear = new Button( \"Clear All Flora\", \"delete_sweep\" );\n\t\t\tclear.ToolTip = \"Remove every painted instance from the target Flora Renderer\";\n\t\t\tclear.Clicked += () =>\n\t\t\t{\n\t\t\t\tvar target = ResolveTarget();\n\t\t\t\tif ( !target.IsValid() || target.Storage is null )\n\t\t\t\t\treturn;\n\n\t\t\t\t// Wiping the whole painted set has no undo, so this one asks first.\n\t\t\t\tDialog.AskConfirm(\n\t\t\t\t\t() =>\n\t\t\t\t\t{\n\t\t\t\t\t\ttarget.Storage.ClearAll();\n\t\t\t\t\t\ttarget.MarkDirty();\n\t\t\t\t\t},\n\t\t\t\t\t\"Are you sure you want to delete all flora? This action cannot be undone.\",\n\t\t\t\t\t\"Delete All Flora\",\n\t\t\t\t\t\"Delete\",\n\t\t\t\t\t\"Cancel\" );\n\t\t\t};\n\t\t\tgroup.Add( clear );\n\t\t}\n\n\t\tsidebar.Layout.AddStretchCell();\n\n\t\treturn sidebar;\n\t}\n\n\tpublic override void OnUpdate()\n\t{\n\t\t_erasing = Gizmo.IsCtrlPressed;\n\n\t\tDrawBrushPreview();\n\n\t\tGizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 999999 ) );\n\n\t\tif ( Gizmo.IsLeftMouseDown )\n\t\t{\n\t\t\tif ( !_dragging )\n\t\t\t{\n\t\t\t\t_dragging = true;\n\t\t\t\t_lastPaintPosition = Vector3.Zero;\n\t\t\t}\n\n\t\t\tOnPaintUpdate();\n\t\t}\n\t\telse if ( _dragging )\n\t\t{\n\t\t\t_dragging = false;\n\t\t\t_lastPaintPosition = Vector3.Zero;\n\n\t\t\tif ( _painted )\n\t\t\t{\n\t\t\t\tResolveTarget()?.MarkDirty();\n\t\t\t\t_painted = false;\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Uses the selected renderer when there is one, otherwise the last used, otherwise the only one\n\t/// in the scene. Creating one implicitly would leave stray components behind every time someone\n\t/// opens the tool.\n\t/// </summary>\n\tprivate FloraRenderer ResolveTarget()\n\t{\n\t\tvar selected = Selection\n\t\t\t.OfType<GameObject>()\n\t\t\t.Select( go => go.Components.Get<FloraRenderer>( FindMode.EnabledInSelfAndDescendants ) )\n\t\t\t.FirstOrDefault( r => r.IsValid() );\n\n\t\tif ( selected.IsValid() )\n\t\t{\n\t\t\t_target = selected;\n\t\t\treturn _target;\n\t\t}\n\n\t\tif ( _target.IsValid() )\n\t\t\treturn _target;\n\n\t\t_target = Scene.GetAllComponents<FloraRenderer>().FirstOrDefault();\n\t\treturn _target;\n\t}\n\n\tprivate void OnPaintUpdate()\n\t{\n\t\tvar target = ResolveTarget();\n\t\tif ( !target.IsValid() || target.Storage is null || !target.Definition.IsValid() )\n\t\t\treturn;\n\n\t\tvar cursor = TraceCursor();\n\t\tif ( !cursor.Hit )\n\t\t\treturn;\n\n\t\tif ( _lastPaintPosition != Vector3.Zero &&\n\t\t\t Vector3.DistanceBetween( cursor.HitPosition, _lastPaintPosition ) < PaintStepDistance )\n\t\t\treturn;\n\n\t\t_lastPaintPosition = cursor.HitPosition;\n\n\t\tvar radius = (float)BrushSettings.Size;\n\t\tvar strength = BrushSettings.Opacity;\n\n\t\tif ( _erasing )\n\t\t{\n\t\t\ttarget.Storage.Erase( cursor.HitPosition, radius, strength );\n\t\t\t_painted = true;\n\t\t\treturn;\n\t\t}\n\n\t\tPaintCoverage( target, cursor.HitPosition, radius, strength );\n\t\t_painted = true;\n\t}\n\n\t/// <summary>\n\t/// Walks every coverage cell the brush touches and traces straight down onto the world, baking\n\t/// the surface height and normal so instances sit on whatever geometry is there. Nothing is\n\t/// placed here - the renderer derives the actual trunks from this coverage plus its seed.\n\t/// </summary>\n\tprivate void PaintCoverage( FloraRenderer target, Vector3 center, float radius, float strength )\n\t{\n\t\tvar definition = target.Definition;\n\t\tvar storage = target.Storage;\n\n\t\tvar radiusSquared = radius * radius;\n\n\t\tvar minX = (int)MathF.Floor( (center.x - radius) / FloraStorage.CellSize );\n\t\tvar maxX = (int)MathF.Floor( (center.x + radius) / FloraStorage.CellSize );\n\t\tvar minY = (int)MathF.Floor( (center.y - radius) / FloraStorage.CellSize );\n\t\tvar maxY = (int)MathF.Floor( (center.y + radius) / FloraStorage.CellSize );\n\n\t\t// Enough headroom to find the surface from above without punching through overhangs the\n\t\t// brush was never aimed at.\n\t\tvar traceHeight = radius + 2048.0f;\n\n\t\tfor ( var cy = minY; cy <= maxY; cy++ )\n\t\t{\n\t\t\tfor ( var cx = minX; cx <= maxX; cx++ )\n\t\t\t{\n\t\t\t\tvar wx = (cx + 0.5f) * FloraStorage.CellSize;\n\t\t\t\tvar wy = (cy + 0.5f) * FloraStorage.CellSize;\n\n\t\t\t\tvar dx = wx - center.x;\n\t\t\t\tvar dy = wy - center.y;\n\t\t\t\tvar distSq = dx * dx + dy * dy;\n\n\t\t\t\tif ( distSq > radiusSquared )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar from = new Vector3( wx, wy, center.z + traceHeight );\n\t\t\t\tvar to = new Vector3( wx, wy, center.z - traceHeight );\n\n\t\t\t\tvar tr = Scene.Trace.Ray( from, to )\n\t\t\t\t\t.UseRenderMeshes( true )\n\t\t\t\t\t.WithTag( \"solid\" )\n\t\t\t\t\t.Run();\n\n\t\t\t\tif ( !tr.Hit )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tif ( tr.Normal.z < definition.SlopeLimit )\n\t\t\t\t\tcontinue;\n\n\t\t\t\t// Soft edge, so overlapping strokes build up smoothly instead of leaving a disc.\n\t\t\t\tvar falloff = 1.0f - MathF.Sqrt( distSq ) / radius;\n\t\t\t\tvar added = strength * MathF.Pow( falloff, 0.5f );\n\n\t\t\t\tvar existing = storage.GetCell( wx, wy ).Density;\n\t\t\t\tvar density = Math.Clamp( existing + added, 0.0f, 1.0f );\n\n\t\t\t\tstorage.SetCell( wx, wy, density, tr.HitPosition.z, tr.Normal, _entryIndex );\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Fills the entry dropdown from the target definition. Rebuilt on demand, since entries can be\n\t/// added or changed while the tool is open.\n\t/// </summary>\n\tprivate void RebuildEntryOptions()\n\t{\n\t\tif ( _entryDropdown is null )\n\t\t\treturn;\n\n\t\t_entryDropdown.Clear();\n\t\t_entryDropdown.AddItem( \"Mixed (by weight)\", \"shuffle\", () => _entryIndex = MixedEntryIndex );\n\n\t\tvar definition = ResolveTarget()?.Definition;\n\t\tif ( !definition.IsValid() || definition.Entries is null )\n\t\t\treturn;\n\n\t\tfor ( var i = 0; i < definition.Entries.Count; i++ )\n\t\t{\n\t\t\tvar entry = definition.Entries[i];\n\t\t\tif ( entry?.HasModel is not true )\n\t\t\t\tcontinue;\n\n\t\t\tvar index = i;\n\t\t\tvar name = System.IO.Path.GetFileNameWithoutExtension( entry.Model.ResourcePath );\n\n\t\t\t_entryDropdown.AddItem( name, \"park\", () => _entryIndex = index );\n\t\t}\n\t}\n\n\tprivate SceneTraceResult TraceCursor() =>\n\t\tScene.Trace.Ray( Gizmo.CurrentRay, 100000 )\n\t\t\t.UseRenderMeshes( true )\n\t\t\t.WithTag( \"solid\" )\n\t\t\t.Run();\n\n\tprivate void DrawBrushPreview()\n\t{\n\t\tvar tr = TraceCursor();\n\t\tif ( !tr.Hit )\n\t\t\treturn;\n\n\t\tusing ( Gizmo.Scope( \"FloraBrush\" ) )\n\t\t{\n\t\t\tGizmo.Draw.Color = _erasing\n\t\t\t\t? Color.FromBytes( 250, 150, 150 )\n\t\t\t\t: Color.FromBytes( 160, 230, 150 );\n\n\t\t\tGizmo.Draw.LineCircle( tr.HitPosition + tr.Normal * 1.0f, tr.Normal, BrushSettings.Size );\n\t\t\tGizmo.Draw.LineCircle( tr.HitPosition + tr.Normal * 1.0f, tr.Normal, BrushSettings.Size * 0.5f );\n\t\t}\n\t}\n}\n"
},
{
"Ident": "redsnail.floratool",
"Path": "Code/FloraRenderer.cs",
"FileName": "FloraRenderer.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 343456,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.FloraTool;\n\n/// <summary>\n/// Renders painted flora. Coverage is stored per chunk and instances are regenerated from it plus\n/// <see cref=\"Seed\"/>, so the scene file holds a density map rather than a transform per tree - the\n/// difference between a few megabytes and something a repository will refuse.\n///\n/// Chunks become scene objects only within the definition's stream radius. Scene objects rather than\n/// a hand-rolled instanced draw because they take part in every pass the engine runs: the depth\n/// prepass, the shadow cascades, and per-object LOD using the model's own compiled distances.\n/// Standard instancing still batches them into few draw calls.\n/// </summary>\n[Icon( \"park\" ), Group( \"Flora\" ), Title( \"Flora Renderer\" )]\npublic sealed partial class FloraRenderer : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer\n{\n\t/// <summary>A chunk's generated instances and the scene objects currently standing for them.</summary>\n\tprivate sealed class LiveChunk\n\t{\n\t\tpublic List<FloraGenerator.Instance> Instances = [];\n\t\tpublic List<SceneObject> SceneObjects = [];\n\n\t\t/// <summary>\n\t\t/// Whether this chunk is currently allowed to cast. Tracked so the flags are only touched\n\t\t/// when a chunk crosses the shadow boundary, rather than every object every frame.\n\t\t/// </summary>\n\t\tpublic bool ShadowsEnabled = true;\n\t}\n\n\t[Property, Group( \"General\" )]\n\tpublic FloraDefinition Definition { get; set; }\n\n\t/// <summary>\n\t/// Decides exactly where each instance lands within the painted coverage. Change it to reshuffle\n\t/// a whole forest without repainting; keep it fixed and the same trees stand in the same places\n\t/// every run, on every machine.\n\t/// </summary>\n\t[Property, Group( \"General\" )]\n\tpublic int Seed\n\t{\n\t\tget => field;\n\t\tset\n\t\t{\n\t\t\tif ( field == value ) return;\n\t\t\tfield = value;\n\t\t\tMarkDirty();\n\t\t}\n\t}\n\n\t/// <summary>Painted coverage. Serialized as a binary blob, not JSON.</summary>\n\t[Property, Hide]\n\tpublic FloraStorage Storage { get; set; } = new();\n\n\tprivate readonly Dictionary<FloraStorage.ChunkCoord, LiveChunk> _live = [];\n\tprivate readonly List<FloraStorage.ChunkCoord> _wantedChunks = [];\n\tprivate readonly List<FloraStorage.ChunkCoord> _staleChunks = [];\n\n\t// Reused across chunk builds so streaming doesn't allocate a fresh list per chunk.\n\tprivate readonly List<FloraGenerator.Instance> _scratchInstances = [];\n\n\tprivate int _builtRevision = -1;\n\tprivate Vector3 _lastStreamOrigin;\n\tprivate bool _hasStreamOrigin;\n\n\t/// <summary>\n\t/// Restreaming walks every painted chunk, so it only happens once the viewer has moved far enough\n\t/// for the answer to have changed. A fraction of a chunk keeps the boundary from thrashing.\n\t/// </summary>\n\tprivate const float StreamRefreshDistance = FloraStorage.ChunkSize * 0.25f;\n\n\tprotected override void OnEnabled()\n\t{\n\t\tStorage ??= new FloraStorage();\n\n\t\t// Scene objects were deleted on disable, so a matching revision would leave us thinking the\n\t\t// world is already built when nothing is in it.\n\t\t_builtRevision = -1;\n\t\t_hasStreamOrigin = false;\n\t}\n\n\tprotected override void OnDisabled()\n\t{\n\t\tReleaseAllChunks();\n\t\tReleaseCollision();\n\n\t\t_builtRevision = -1;\n\t\t_hasStreamOrigin = false;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tvar viewer = GetViewerPosition();\n\t\tif ( !viewer.HasValue )\n\t\t\treturn;\n\n\t\tUpdateStreaming( viewer.Value );\n\t\tUpdateCollision( viewer.Value );\n\t}\n\n\t/// <summary>\n\t/// What streaming follows. While editing that is the viewport camera, so flora appears around\n\t/// what you are looking at rather than wherever the game camera is parked.\n\t/// </summary>\n\tprivate Vector3? GetViewerPosition()\n\t{\n\t\tif ( Scene.IsEditor )\n\t\t{\n\t\t\tvar editorCamera = Application.Editor?.Camera;\n\t\t\tif ( editorCamera.IsValid() )\n\t\t\t\treturn editorCamera.WorldPosition;\n\t\t}\n\n\t\treturn Scene.Camera.IsValid() ? Scene.Camera.WorldPosition : null;\n\t}\n\n\tprivate void UpdateStreaming( Vector3 origin )\n\t{\n\t\tif ( Storage is null || !Definition.IsValid() )\n\t\t{\n\t\t\tReleaseAllChunks();\n\t\t\treturn;\n\t\t}\n\n\t\t// Painting or reseeding invalidates everything regardless of whether the viewer moved.\n\t\tvar dirty = _builtRevision != Storage.Revision;\n\n\t\tif ( !dirty && _hasStreamOrigin && origin.Distance( _lastStreamOrigin ) < StreamRefreshDistance )\n\t\t\treturn;\n\n\t\tif ( dirty )\n\t\t{\n\t\t\tReleaseAllChunks();\n\t\t\t_builtRevision = Storage.Revision;\n\t\t}\n\n\t\t_lastStreamOrigin = origin;\n\t\t_hasStreamOrigin = true;\n\n\t\tGatherWantedChunks( origin );\n\t\tSyncChunks( origin );\n\t}\n\n\tprivate void GatherWantedChunks( Vector3 origin )\n\t{\n\t\t_wantedChunks.Clear();\n\n\t\t// A chunk's near corner can be in range while its centre is not, hence the circumradius.\n\t\tvar radius = Definition.StreamRadius + FloraStorage.ChunkSize * 0.7072f;\n\t\tvar radiusSquared = radius * radius;\n\n\t\tforeach ( var (coord, _) in Storage.Chunks )\n\t\t{\n\t\t\tvar center = FloraStorage.ChunkCenter( coord );\n\n\t\t\tvar dx = center.x - origin.x;\n\t\t\tvar dy = center.y - origin.y;\n\n\t\t\tif ( dx * dx + dy * dy > radiusSquared )\n\t\t\t\tcontinue;\n\n\t\t\t_wantedChunks.Add( coord );\n\t\t}\n\t}\n\n\tprivate void SyncChunks( Vector3 origin )\n\t{\n\t\t_staleChunks.Clear();\n\n\t\tforeach ( var (coord, _) in _live )\n\t\t{\n\t\t\tif ( !_wantedChunks.Contains( coord ) )\n\t\t\t\t_staleChunks.Add( coord );\n\t\t}\n\n\t\tforeach ( var coord in _staleChunks )\n\t\t\tReleaseChunk( coord );\n\n\t\tforeach ( var coord in _wantedChunks )\n\t\t{\n\t\t\tif ( _live.ContainsKey( coord ) )\n\t\t\t\tcontinue;\n\n\t\t\tBuildChunk( coord, origin );\n\t\t}\n\n\t\tUpdateChunkShadows( origin );\n\t}\n\n\t/// <summary>\n\t/// Turns shadow casting off for chunks past the shadow distance. Evaluated per chunk rather than\n\t/// per instance, and only written when a chunk actually crosses the boundary, so a stationary\n\t/// camera costs nothing here.\n\t/// </summary>\n\tprivate void UpdateChunkShadows( Vector3 origin )\n\t{\n\t\tforeach ( var (coord, chunk) in _live )\n\t\t{\n\t\t\tvar wanted = ChunkCastsShadows( coord, origin );\n\t\t\tif ( wanted == chunk.ShadowsEnabled )\n\t\t\t\tcontinue;\n\n\t\t\tchunk.ShadowsEnabled = wanted;\n\t\t\tApplyChunkShadows( chunk );\n\t\t}\n\t}\n\n\tprivate bool ChunkCastsShadows( FloraStorage.ChunkCoord coord, Vector3 origin )\n\t{\n\t\tvar distance = Definition.ShadowDistance;\n\t\tif ( distance <= 0.0f )\n\t\t\treturn true;\n\n\t\t// Measured to the chunk's near edge via its circumradius, so a chunk is only cut off once all\n\t\t// of it is beyond the limit.\n\t\tvar limit = distance + FloraStorage.ChunkSize * 0.7072f;\n\n\t\tvar center = FloraStorage.ChunkCenter( coord );\n\t\tvar dx = center.x - origin.x;\n\t\tvar dy = center.y - origin.y;\n\n\t\treturn dx * dx + dy * dy <= limit * limit;\n\t}\n\n\t/// <summary>\n\t/// The per-entry CastShadows setting is the ceiling - distance can only ever take shadows away,\n\t/// never grant them to an entry the artist turned them off for.\n\t/// </summary>\n\tprivate void ApplyChunkShadows( LiveChunk chunk )\n\t{\n\t\tfor ( var i = 0; i < chunk.SceneObjects.Count && i < chunk.Instances.Count; i++ )\n\t\t{\n\t\t\tvar sceneObject = chunk.SceneObjects[i];\n\t\t\tif ( !sceneObject.IsValid() )\n\t\t\t\tcontinue;\n\n\t\t\tvar entry = Definition.GetEntry( chunk.Instances[i].EntryIndex );\n\t\t\tsceneObject.Flags.CastShadows = chunk.ShadowsEnabled && entry?.CastShadows is true;\n\t\t}\n\t}\n\n\tprivate void BuildChunk( FloraStorage.ChunkCoord coord, Vector3 origin )\n\t{\n\t\tif ( !Storage.Chunks.TryGetValue( coord, out var cells ) )\n\t\t\treturn;\n\n\t\tvar world = Scene.SceneWorld;\n\t\tif ( !world.IsValid() )\n\t\t\treturn;\n\n\t\tvar chunk = new LiveChunk();\n\t\tchunk.ShadowsEnabled = ChunkCastsShadows( coord, origin );\n\n\t\t_scratchInstances.Clear();\n\t\tFloraGenerator.GenerateChunk( coord, cells, Definition, Seed, _scratchInstances );\n\n\t\t// Instances and scene objects are kept strictly parallel - anything whose entry no longer\n\t\t// resolves is dropped from both. Skipping only the scene object would slide the two lists out\n\t\t// of step, and the shadow and collision paths index one by the other.\n\t\tfor ( var i = 0; i < _scratchInstances.Count; i++ )\n\t\t{\n\t\t\tvar instance = _scratchInstances[i];\n\n\t\t\tvar entry = Definition.GetEntry( instance.EntryIndex );\n\t\t\tif ( entry is null )\n\t\t\t\tcontinue;\n\n\t\t\tvar sceneObject = new SceneObject( world, entry.Model, instance.ToTransform() );\n\t\t\tsceneObject.Flags.CastShadows = chunk.ShadowsEnabled && entry.CastShadows;\n\n\t\t\tchunk.Instances.Add( instance );\n\t\t\tchunk.SceneObjects.Add( sceneObject );\n\t\t}\n\n\t\t_live[coord] = chunk;\n\t}\n\n\tprivate void ReleaseChunk( FloraStorage.ChunkCoord coord )\n\t{\n\t\tif ( !_live.Remove( coord, out var chunk ) )\n\t\t\treturn;\n\n\t\tforeach ( var sceneObject in chunk.SceneObjects )\n\t\t{\n\t\t\tif ( sceneObject.IsValid() )\n\t\t\t\tsceneObject.Delete();\n\t\t}\n\n\t\tchunk.SceneObjects.Clear();\n\t\tchunk.Instances.Clear();\n\t}\n\n\tprivate void ReleaseAllChunks()\n\t{\n\t\tforeach ( var (_, chunk) in _live )\n\t\t{\n\t\t\tforeach ( var sceneObject in chunk.SceneObjects )\n\t\t\t{\n\t\t\t\tif ( sceneObject.IsValid() )\n\t\t\t\t\tsceneObject.Delete();\n\t\t\t}\n\t\t}\n\n\t\t_live.Clear();\n\t\t_wantedChunks.Clear();\n\t\t_staleChunks.Clear();\n\t}\n\n\t/// <summary>\n\t/// Called by the editor tool after painting, so the next frame regenerates. Also fires when the\n\t/// seed changes.\n\t/// </summary>\n\tpublic void MarkDirty()\n\t{\n\t\t_builtRevision = -1;\n\t\t_hasStreamOrigin = false;\n\t}\n\n\t/// <summary>Total instances currently streamed in. Useful when tuning density and stream radius.</summary>\n\tpublic int LiveInstanceCount\n\t{\n\t\tget\n\t\t{\n\t\t\tvar count = 0;\n\t\t\tforeach ( var (_, chunk) in _live )\n\t\t\t\tcount += chunk.SceneObjects.Count;\n\t\t\treturn count;\n\t\t}\n\t}\n\n\tprotected override void DrawGizmos()\n\t{\n\t\tif ( !Gizmo.IsSelected || Storage is null || Storage.ChunkCount == 0 )\n\t\t\treturn;\n\n\t\tGizmo.Draw.Color = Color.Green.WithAlpha( 0.25f );\n\n\t\tforeach ( var (coord, _) in Storage.Chunks )\n\t\t{\n\t\t\tvar origin = FloraStorage.ChunkOrigin( coord );\n\n\t\t\tvar mins = WorldTransform.PointToLocal( new Vector3( origin.x, origin.y, 0 ) );\n\t\t\tvar maxs = WorldTransform.PointToLocal( new Vector3(\n\t\t\t\torigin.x + FloraStorage.ChunkSize, origin.y + FloraStorage.ChunkSize, 0 ) );\n\n\t\t\tGizmo.Draw.LineBBox( new BBox( mins, maxs ) );\n\t\t}\n\t}\n}\n"
},
{
"Ident": "redsnail.floratool",
"Path": "FloraDefinition.cs",
"FileName": "FloraDefinition.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 343456,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.FloraTool;\n\n/// <summary>\n/// One kind of flora the brush can plant. Weight decides how often it comes up relative to the\n/// other entries in the definition.\n/// </summary>\npublic sealed class FloraEntry\n{\n\t[Property]\n\tpublic Model Model { get; set; }\n\n\t/// <summary>Relative chance of this entry being picked. Zero excludes it without deleting it.</summary>\n\t[Property, Range( 0, 10 )]\n\tpublic float Weight { get; set; } = 1.0f;\n\n\t[Property]\n\tpublic RangedFloat Scale { get; set; } = new( 0.85f, 1.25f );\n\n\t/// <summary>Random spin about the vertical axis, so repeated instances don't read as clones.</summary>\n\t[Property]\n\tpublic bool RandomYaw { get; set; } = true;\n\n\t/// <summary>\n\t/// Tilts the instance toward the surface normal. Right for rocks and bushes, usually wrong for\n\t/// trees - a trunk growing perpendicular to a hillside looks broken.\n\t/// </summary>\n\t[Property, Range( 0, 1 )]\n\tpublic float AlignToNormal { get; set; } = 0.0f;\n\n\t/// <summary>Random lean away from vertical, in degrees. A little goes a long way on trees.</summary>\n\t[Property, Range( 0, 45 )]\n\tpublic float RandomTilt { get; set; } = 0.0f;\n\n\t/// <summary>Sinks the instance into the ground, hiding the seam where the base meets the surface.</summary>\n\t[Property, Range( 0, 64 )]\n\tpublic float SinkDepth { get; set; } = 0.0f;\n\n\t/// <summary>\n\t/// Gives this entry real collision. Colliders are only created near the player, so this is about\n\t/// whether the flora is solid at all - not about paying for every painted instance at once.\n\t/// </summary>\n\t[Property, Group( \"Physics\" )]\n\tpublic bool EnablePhysics { get; set; } = true;\n\n\t[Property, Group( \"Rendering\" )]\n\tpublic bool CastShadows { get; set; } = true;\n\n\tpublic bool HasModel => Model is not null && !string.IsNullOrEmpty( Model.ResourcePath );\n}\n\n/// <summary>\n/// A palette of flora plus the rules used when painting it. Shared by every\n/// <see cref=\"FloraRenderer\"/> that references it, so a whole world can be retuned from one asset.\n/// </summary>\n[AssetType( Name = \"Flora Definition\", Extension = \"floradef\", Category = \"Flora\" )]\npublic sealed class FloraDefinition : GameResource\n{\n\t[Property]\n\tpublic List<FloraEntry> Entries { get; set; } = [];\n\n\t/// <summary>\n\t/// Instances a fully painted cell can hold. Coverage scales this, so it sets the ceiling on how\n\t/// tightly flora can pack - raise it for undergrowth, leave it low for trees.\n\t/// </summary>\n\t[Property, Group( \"Painting\" ), Range( 1, 16 )]\n\tpublic int MaxPerCell { get; set; } = 2;\n\n\t/// <summary>Minimum ground normal Z. Steeper than this and nothing plants, so cliffs stay bare.</summary>\n\t[Property, Group( \"Painting\" ), Range( 0, 1 )]\n\tpublic float SlopeLimit { get; set; } = 0.6f;\n\n\t/// <summary>\n\t/// Radius around the viewer within which chunks are turned into scene objects. Chunks beyond it\n\t/// keep their painted coverage but cost nothing to render.\n\t/// </summary>\n\t[Property, Group( \"Streaming\" ), Range( 2000, 100000 )]\n\tpublic float StreamRadius { get; set; } = 25000.0f;\n\n\t/// <summary>\n\t/// Distance past which flora stops casting shadows. Shadow cascades ignore the view frustum, so\n\t/// distant trees are rendered into them whichever way the camera faces - dropping them is one of\n\t/// the few savings that applies even when you are looking away.\n\t///\n\t/// Set it too low and you will see shadows wink out as chunks cross the boundary, most obviously\n\t/// under a low sun where far geometry casts long shadows into view. Zero disables the cutoff.\n\t/// </summary>\n\t[Property, Group( \"Streaming\" ), Range( 0, 50000 )]\n\tpublic float ShadowDistance { get; set; } = 10000.0f;\n\n\t/// <summary>\n\t/// Radius around the viewer within which entries flagged <see cref=\"FloraEntry.EnablePhysics\"/>\n\t/// get real colliders. Keep it just past where the player can reach.\n\t/// </summary>\n\t[Property, Group( \"Physics\" ), Range( 256, 20000 )]\n\tpublic float CollisionRadius { get; set; } = 4000.0f;\n\n\t/// <summary>\n\t/// The entry at an index, or null when the index no longer resolves - entries can be removed\n\t/// after coverage has already been painted naming them.\n\t/// </summary>\n\tpublic FloraEntry GetEntry( int index )\n\t{\n\t\tif ( Entries is null || index < 0 || index >= Entries.Count )\n\t\t\treturn null;\n\n\t\tvar entry = Entries[index];\n\t\treturn entry?.HasModel is true ? entry : null;\n\t}\n}\n"
},
{
"Ident": "redsnail.floratool",
"Path": "FloraRenderer.Collision.cs",
"FileName": "FloraRenderer.Collision.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 343456,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.FloraTool;\n\n/// <summary>\n/// Collision for painted flora. Instances only exist as scene objects, so nothing is solid until a\n/// collider is made for it - and those are made only for instances near the viewer and recycled as\n/// it moves, keeping physics cost tied to what is reachable rather than to the whole forest.\n/// </summary>\npublic sealed partial class FloraRenderer\n{\n\tprivate readonly record struct CollisionKey( FloraStorage.ChunkCoord Chunk, int Index );\n\n\tprivate readonly Dictionary<CollisionKey, GameObject> _colliders = [];\n\n\t// A set rather than a list: SyncColliders tests every live collider against it, so a linear scan\n\t// there would be quadratic once a few hundred are in range.\n\tprivate readonly HashSet<CollisionKey> _wantedColliders = [];\n\tprivate readonly List<CollisionKey> _staleColliders = [];\n\n\tprivate GameObject _collisionRoot;\n\tprivate Vector3 _lastCollisionOrigin;\n\tprivate bool _hasCollisionOrigin;\n\tprivate int _collisionRevision = -1;\n\n\t/// <summary>\n\t/// Rebuilding walks every streamed instance, so it only happens once the viewer has moved far\n\t/// enough for the answer to have changed.\n\t/// </summary>\n\tprivate const float CollisionRefreshDistance = 256.0f;\n\n\tprivate void UpdateCollision( Vector3 origin )\n\t{\n\t\tif ( !Definition.IsValid() || Definition.CollisionRadius <= 0.0f )\n\t\t{\n\t\t\tReleaseCollision();\n\t\t\treturn;\n\t\t}\n\n\t\tvar storageChanged = Storage is null || _collisionRevision != Storage.Revision;\n\n\t\tif ( !storageChanged && _hasCollisionOrigin &&\n\t\t\t origin.Distance( _lastCollisionOrigin ) < CollisionRefreshDistance )\n\t\t\treturn;\n\n\t\t_collisionRevision = Storage?.Revision ?? -1;\n\t\t_lastCollisionOrigin = origin;\n\t\t_hasCollisionOrigin = true;\n\n\t\tGatherWantedColliders( origin );\n\t\tSyncColliders();\n\t}\n\n\t/// <summary>\n\t/// Only streamed chunks are considered. Collision radius should sit well inside the stream radius\n\t/// anyway, so anything outside it has no business being solid.\n\t/// </summary>\n\tprivate void GatherWantedColliders( Vector3 origin )\n\t{\n\t\t_wantedColliders.Clear();\n\n\t\tvar radiusSquared = Definition.CollisionRadius * Definition.CollisionRadius;\n\n\t\tforeach ( var (coord, chunk) in _live )\n\t\t{\n\t\t\tfor ( var i = 0; i < chunk.Instances.Count; i++ )\n\t\t\t{\n\t\t\t\tvar instance = chunk.Instances[i];\n\n\t\t\t\tif ( instance.Position.DistanceSquared( origin ) > radiusSquared )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar entry = Definition.GetEntry( instance.EntryIndex );\n\t\t\t\tif ( entry?.EnablePhysics is not true )\n\t\t\t\t\tcontinue;\n\n\t\t\t\t_wantedColliders.Add( new CollisionKey( coord, i ) );\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void SyncColliders()\n\t{\n\t\t// Drop what fell out of range first, so those objects are free to be reused this same frame.\n\t\t_staleColliders.Clear();\n\n\t\tforeach ( var (key, gameObject) in _colliders )\n\t\t{\n\t\t\tif ( gameObject.IsValid() && _wantedColliders.Contains( key ) )\n\t\t\t\tcontinue;\n\n\t\t\t_staleColliders.Add( key );\n\t\t}\n\n\t\tforeach ( var key in _staleColliders )\n\t\t{\n\t\t\tif ( _colliders.Remove( key, out var gameObject ) && gameObject.IsValid() )\n\t\t\t\tgameObject.Destroy();\n\t\t}\n\n\t\tforeach ( var key in _wantedColliders )\n\t\t{\n\t\t\tif ( _colliders.ContainsKey( key ) )\n\t\t\t\tcontinue;\n\n\t\t\tvar gameObject = CreateCollider( key );\n\t\t\tif ( gameObject.IsValid() )\n\t\t\t\t_colliders[key] = gameObject;\n\t\t}\n\t}\n\n\tprivate GameObject CreateCollider( CollisionKey key )\n\t{\n\t\tif ( !_live.TryGetValue( key.Chunk, out var chunk ) )\n\t\t\treturn null;\n\n\t\tif ( key.Index < 0 || key.Index >= chunk.Instances.Count )\n\t\t\treturn null;\n\n\t\tvar instance = chunk.Instances[key.Index];\n\n\t\tvar entry = Definition.GetEntry( instance.EntryIndex );\n\t\tif ( entry is null )\n\t\t\treturn null;\n\n\t\tEnsureCollisionRoot();\n\n\t\tvar gameObject = new GameObject( true, \"FloraCollider\" )\n\t\t{\n\t\t\tParent = _collisionRoot,\n\t\t\tWorldTransform = instance.ToTransform(),\n\t\t};\n\n\t\t// Not saved with the scene and not shown in the hierarchy - these are transient physics\n\t\t// proxies for geometry that is regenerated from the seed anyway.\n\t\tgameObject.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;\n\n\t\tvar collider = gameObject.Components.Create<ModelCollider>();\n\t\tcollider.Model = entry.Model;\n\t\tcollider.Static = true;\n\n\t\treturn gameObject;\n\t}\n\n\tprivate void EnsureCollisionRoot()\n\t{\n\t\tif ( _collisionRoot.IsValid() )\n\t\t\treturn;\n\n\t\t_collisionRoot = new GameObject( true, \"Flora Colliders\" ) { Parent = GameObject };\n\t\t_collisionRoot.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;\n\t}\n\n\tprivate void ReleaseCollision()\n\t{\n\t\tforeach ( var (_, gameObject) in _colliders )\n\t\t{\n\t\t\tif ( gameObject.IsValid() )\n\t\t\t\tgameObject.Destroy();\n\t\t}\n\n\t\t_colliders.Clear();\n\t\t_wantedColliders.Clear();\n\t\t_staleColliders.Clear();\n\n\t\tif ( _collisionRoot.IsValid() )\n\t\t\t_collisionRoot.Destroy();\n\n\t\t_collisionRoot = null;\n\t\t_hasCollisionOrigin = false;\n\t\t_collisionRevision = -1;\n\t}\n}\n"
},
{
"Ident": "redsnail.floratool",
"Path": "Code/FloraGenerator.cs",
"FileName": "FloraGenerator.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 343456,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.FloraTool;\n\n/// <summary>\n/// Turns painted coverage into concrete instances. Everything here is a pure function of the chunk\n/// coordinate, the cell contents and the seed - no state, no RNG object - so a chunk regenerates\n/// identically every run, on every machine, however many times it is streamed in and out.\n/// </summary>\npublic static class FloraGenerator\n{\n\tpublic readonly record struct Instance( int EntryIndex, Vector3 Position, Rotation Rotation, float Scale )\n\t{\n\t\tpublic readonly Transform ToTransform() => new( Position, Rotation, Scale );\n\t}\n\n\t/// <summary>\n\t/// Integer avalanche hash. Deterministic across runs and platforms, which the framework RNG is\n\t/// not guaranteed to be, and cheap enough to call several times per instance.\n\t/// </summary>\n\tprivate static uint Hash( uint x )\n\t{\n\t\tx ^= x >> 16;\n\t\tx *= 0x7feb352du;\n\t\tx ^= x >> 15;\n\t\tx *= 0x846ca68bu;\n\t\tx ^= x >> 16;\n\t\treturn x;\n\t}\n\n\tprivate static float HashFloat( uint x ) => Hash( x ) * (1.0f / 4294967296.0f);\n\n\t/// <summary>\n\t/// Generates every instance for one chunk, appending into <paramref name=\"results\"/>.\n\t/// </summary>\n\tpublic static void GenerateChunk( FloraStorage.ChunkCoord coord, FloraStorage.Cell[] cells,\n\t\tFloraDefinition definition, int seed, List<Instance> results )\n\t{\n\t\tif ( cells is null || definition is null )\n\t\t\treturn;\n\n\t\tvar origin = FloraStorage.ChunkOrigin( coord );\n\t\tvar maxPerCell = Math.Max( definition.MaxPerCell, 1 );\n\n\t\t// Mixing the chunk coordinate into the seed keeps neighbouring chunks from sharing a\n\t\t// sequence, which would otherwise show up as a visible repeating pattern across the world.\n\t\tvar chunkSeed = Hash( (uint)seed\n\t\t\t^ Hash( (uint)coord.X * 73856093u )\n\t\t\t^ Hash( (uint)coord.Y * 19349663u ) );\n\n\t\tfor ( var cellIndex = 0; cellIndex < cells.Length; cellIndex++ )\n\t\t{\n\t\t\tvar cell = cells[cellIndex];\n\n\t\t\tvar density = cell.Density;\n\t\t\tif ( density <= 0.0f )\n\t\t\t\tcontinue;\n\n\t\t\tif ( cell.Normal.z < definition.SlopeLimit )\n\t\t\t\tcontinue;\n\n\t\t\tvar cellSeed = Hash( chunkSeed ^ Hash( (uint)cellIndex * 0x9e3779b9u ) );\n\n\t\t\tvar cx = cellIndex % FloraStorage.ChunkResolution;\n\t\t\tvar cy = cellIndex / FloraStorage.ChunkResolution;\n\n\t\t\tvar cellMinX = origin.x + cx * FloraStorage.CellSize;\n\t\t\tvar cellMinY = origin.y + cy * FloraStorage.CellSize;\n\n\t\t\t// Fractional counts are resolved by a hash rather than rounding, so density reads as a\n\t\t\t// smooth thinning across a field instead of stepping between whole numbers per cell.\n\t\t\tvar exact = density * maxPerCell;\n\t\t\tvar count = (int)exact;\n\t\t\tif ( HashFloat( cellSeed ^ 0x1b56c4e9u ) < exact - count )\n\t\t\t\tcount++;\n\n\t\t\tfor ( var i = 0; i < count; i++ )\n\t\t\t{\n\t\t\t\tvar s = Hash( cellSeed + (uint)i * 0x85ebca6bu );\n\n\t\t\t\tvar entry = ResolveEntry( definition, cell.EntryIndex, s );\n\t\t\t\tif ( entry.Index < 0 )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tresults.Add( BuildInstance( entry.Index, entry.Entry, cell, s, cellMinX, cellMinY ) );\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// A cell either names its entry - painted deliberately with one species selected - or defers to\n\t/// the definition's weights.\n\t/// </summary>\n\tprivate static (int Index, FloraEntry Entry) ResolveEntry( FloraDefinition definition, int cellEntryIndex, uint seed )\n\t{\n\t\tvar entries = definition.Entries;\n\t\tif ( entries is null || entries.Count == 0 )\n\t\t\treturn (-1, null);\n\n\t\tif ( cellEntryIndex < entries.Count )\n\t\t{\n\t\t\tvar named = entries[cellEntryIndex];\n\t\t\treturn named?.HasModel is true ? (cellEntryIndex, named) : (-1, null);\n\t\t}\n\n\t\tvar total = 0.0f;\n\t\tfor ( var i = 0; i < entries.Count; i++ )\n\t\t{\n\t\t\tif ( entries[i]?.HasModel is true && entries[i].Weight > 0.0f )\n\t\t\t\ttotal += entries[i].Weight;\n\t\t}\n\n\t\tif ( total <= 0.0f )\n\t\t\treturn (-1, null);\n\n\t\tvar pick = HashFloat( seed ^ 0x3c6ef372u ) * total;\n\n\t\tfor ( var i = 0; i < entries.Count; i++ )\n\t\t{\n\t\t\tvar entry = entries[i];\n\t\t\tif ( entry?.HasModel is not true || entry.Weight <= 0.0f )\n\t\t\t\tcontinue;\n\n\t\t\tpick -= entry.Weight;\n\t\t\tif ( pick <= 0.0f )\n\t\t\t\treturn (i, entry);\n\t\t}\n\n\t\treturn (-1, null);\n\t}\n\n\tprivate static Instance BuildInstance( int entryIndex, FloraEntry entry, FloraStorage.Cell cell,\n\t\tuint seed, float cellMinX, float cellMinY )\n\t{\n\t\tvar jitterX = HashFloat( seed ^ 0x68bc21ebu );\n\t\tvar jitterY = HashFloat( seed ^ 0x02e5be93u );\n\n\t\tvar x = cellMinX + jitterX * FloraStorage.CellSize;\n\t\tvar y = cellMinY + jitterY * FloraStorage.CellSize;\n\n\t\tvar normal = cell.Normal;\n\n\t\t// The baked height is the cell centre's, so a slope needs the offset carried across to the\n\t\t// jittered position or trunks float on the uphill side and sink on the downhill one.\n\t\tvar offsetX = x - (cellMinX + FloraStorage.CellSize * 0.5f);\n\t\tvar offsetY = y - (cellMinY + FloraStorage.CellSize * 0.5f);\n\t\tvar z = cell.Height - (normal.x * offsetX + normal.y * offsetY) / MathF.Max( normal.z, 0.1f );\n\n\t\tvar position = new Vector3( x, y, z );\n\t\tif ( entry.SinkDepth > 0.0f )\n\t\t\tposition -= normal * entry.SinkDepth;\n\n\t\tvar rotation = entry.RandomYaw\n\t\t\t? Rotation.FromYaw( HashFloat( seed ^ 0x7f4a7c15u ) * 360.0f )\n\t\t\t: Rotation.Identity;\n\n\t\tif ( entry.AlignToNormal > 0.0f )\n\t\t{\n\t\t\tvar aligned = Rotation.LookAt( normal ) * Rotation.FromPitch( 90.0f );\n\t\t\trotation = Rotation.Slerp( rotation, aligned * rotation, entry.AlignToNormal );\n\t\t}\n\n\t\tif ( entry.RandomTilt > 0.0f )\n\t\t{\n\t\t\tvar tiltAngle = HashFloat( seed ^ 0x165667b1u ) * entry.RandomTilt;\n\t\t\tvar tiltDirection = HashFloat( seed ^ 0x27d4eb2fu ) * 360.0f;\n\t\t\trotation *= Rotation.FromAxis( Rotation.FromYaw( tiltDirection ).Forward, tiltAngle );\n\t\t}\n\n\t\tvar scale = MathX.Lerp( entry.Scale.Min, entry.Scale.Max, HashFloat( seed ^ 0xd3a2646cu ) );\n\n\t\treturn new Instance( entryIndex, position, rotation, scale );\n\t}\n}\n"
},
{
"Ident": "redsnail.floratool",
"Path": "FloraStorage.cs",
"FileName": "FloraStorage.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 343456,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.FloraTool;\n\n/// <summary>\n/// Painted flora coverage, stored as a sparse chunked grid of density samples rather than one\n/// transform per tree. Instances are regenerated from this plus a seed, so a forest of a hundred\n/// thousand trees costs a few megabytes instead of tens - which matters because the scene sidecar\n/// has to survive being committed to a repository.\n///\n/// The trade is that positions are derived, not authored: painting decides where flora *can* grow\n/// and how densely, and the seed decides exactly where each trunk lands.\n/// </summary>\npublic sealed class FloraStorage : BlobData\n{\n\tpublic override int Version => 1;\n\n\t/// <summary>Cells along one edge of a chunk.</summary>\n\tpublic const int ChunkResolution = 32;\n\n\t/// <summary>\n\t/// World size of one density cell. Roughly a tree's footprint - each cell holds at most a\n\t/// handful of instances, so this is what bounds how tightly flora can pack.\n\t/// Changing it invalidates every painted scene, so it is a constant rather than a setting.\n\t/// </summary>\n\tpublic const float CellSize = 256.0f;\n\n\tpublic const float ChunkSize = ChunkResolution * CellSize;\n\n\tpublic const int CellsPerChunk = ChunkResolution * ChunkResolution;\n\n\t/// <summary>\n\t/// One coverage sample. Height and normal are baked at paint time so flora sits on whatever\n\t/// geometry was there, without the renderer having to trace anything at load.\n\t/// </summary>\n\tpublic struct Cell\n\t{\n\t\tpublic float Height;\n\n\t\t/// <summary>density (0-7) | normal.x (8-15) | normal.y (16-23) | entry index (24-31)</summary>\n\t\tpublic uint Packed;\n\n\t\tpublic readonly float Density => (Packed & 0xFF) / 255.0f;\n\n\t\t/// <summary>Index into the definition's entry list. 0xFF means \"pick one by weight\".</summary>\n\t\tpublic readonly int EntryIndex => (int)((Packed >> 24) & 0xFF);\n\n\t\tpublic readonly Vector3 Normal\n\t\t{\n\t\t\tget\n\t\t\t{\n\t\t\t\tvar x = ((Packed >> 8) & 0xFF) / 127.5f - 1.0f;\n\t\t\t\tvar y = ((Packed >> 16) & 0xFF) / 127.5f - 1.0f;\n\t\t\t\tvar z = MathF.Sqrt( Math.Clamp( 1.0f - x * x - y * y, 0.0f, 1.0f ) );\n\t\t\t\treturn new Vector3( x, y, z );\n\t\t\t}\n\t\t}\n\n\t\tpublic static uint Pack( float density, Vector3 normal, int entryIndex )\n\t\t{\n\t\t\tvar d = (uint)Math.Clamp( density * 255.0f + 0.5f, 0.0f, 255.0f );\n\t\t\tvar nx = (uint)Math.Clamp( (normal.x + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );\n\t\t\tvar ny = (uint)Math.Clamp( (normal.y + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );\n\t\t\tvar e = (uint)Math.Clamp( entryIndex, 0, 255 );\n\n\t\t\treturn d | (nx << 8) | (ny << 16) | (e << 24);\n\t\t}\n\t}\n\n\tpublic readonly record struct ChunkCoord( int X, int Y );\n\n\tprivate readonly Dictionary<ChunkCoord, Cell[]> _chunks = [];\n\n\t/// <summary>Bumped on every mutation so the renderer knows to regenerate.</summary>\n\tpublic int Revision { get; private set; }\n\n\tpublic int ChunkCount => _chunks.Count;\n\n\tpublic IReadOnlyDictionary<ChunkCoord, Cell[]> Chunks => _chunks;\n\n\tpublic static ChunkCoord WorldToChunk( Vector3 world ) => new(\n\t\t(int)MathF.Floor( world.x / ChunkSize ),\n\t\t(int)MathF.Floor( world.y / ChunkSize ) );\n\n\tpublic static Vector2 ChunkOrigin( ChunkCoord coord ) => new( coord.X * ChunkSize, coord.Y * ChunkSize );\n\n\tpublic static Vector3 ChunkCenter( ChunkCoord coord, float height = 0.0f )\n\t{\n\t\tvar origin = ChunkOrigin( coord );\n\t\treturn new Vector3( origin.x + ChunkSize * 0.5f, origin.y + ChunkSize * 0.5f, height );\n\t}\n\n\tprivate static int WorldToCell( float world ) => (int)MathF.Floor( world / CellSize );\n\n\tprivate static int FloorDiv( int a, int b ) => a >= 0 ? a / b : ~(~a / b);\n\n\tprivate static int Mod( int a, int b )\n\t{\n\t\tvar r = a % b;\n\t\treturn r < 0 ? r + b : r;\n\t}\n\n\t/// <summary>\n\t/// Writes a coverage sample, baking the surface height and normal alongside it. Density of zero\n\t/// frees the sample.\n\t/// </summary>\n\tpublic void SetCell( float worldX, float worldY, float density, float height, Vector3 normal, int entryIndex )\n\t{\n\t\tvar cellX = WorldToCell( worldX );\n\t\tvar cellY = WorldToCell( worldY );\n\t\tvar coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );\n\n\t\tif ( !_chunks.TryGetValue( coord, out var cells ) )\n\t\t{\n\t\t\tif ( density <= 0.0f ) return;\n\n\t\t\tcells = new Cell[CellsPerChunk];\n\t\t\t_chunks[coord] = cells;\n\t\t}\n\n\t\tvar index = Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution );\n\t\tcells[index] = new Cell { Height = height, Packed = Cell.Pack( density, normal, entryIndex ) };\n\n\t\tRevision++;\n\t}\n\n\tpublic Cell GetCell( float worldX, float worldY )\n\t{\n\t\tvar cellX = WorldToCell( worldX );\n\t\tvar cellY = WorldToCell( worldY );\n\t\tvar coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );\n\n\t\tif ( !_chunks.TryGetValue( coord, out var cells ) )\n\t\t\treturn default;\n\n\t\treturn cells[Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution )];\n\t}\n\n\t/// <summary>Reduces coverage in a radius, removing samples that reach zero.</summary>\n\tpublic void Erase( Vector3 center, float radius, float strength )\n\t{\n\t\tvar radiusSquared = radius * radius;\n\n\t\tvar minCellX = WorldToCell( center.x - radius );\n\t\tvar maxCellX = WorldToCell( center.x + radius );\n\t\tvar minCellY = WorldToCell( center.y - radius );\n\t\tvar maxCellY = WorldToCell( center.y + radius );\n\n\t\tvar changed = false;\n\n\t\tfor ( var cy = minCellY; cy <= maxCellY; cy++ )\n\t\t{\n\t\t\tfor ( var cx = minCellX; cx <= maxCellX; cx++ )\n\t\t\t{\n\t\t\t\tvar coord = new ChunkCoord( FloorDiv( cx, ChunkResolution ), FloorDiv( cy, ChunkResolution ) );\n\t\t\t\tif ( !_chunks.TryGetValue( coord, out var cells ) )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar wx = (cx + 0.5f) * CellSize;\n\t\t\t\tvar wy = (cy + 0.5f) * CellSize;\n\t\t\t\tvar dx = wx - center.x;\n\t\t\t\tvar dy = wy - center.y;\n\n\t\t\t\tif ( dx * dx + dy * dy > radiusSquared )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar index = Mod( cy, ChunkResolution ) * ChunkResolution + Mod( cx, ChunkResolution );\n\t\t\t\tref var cell = ref cells[index];\n\n\t\t\t\tif ( (cell.Packed & 0xFF) == 0 )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar density = Math.Max( cell.Density - strength, 0.0f );\n\t\t\t\tcell.Packed = density <= 0.0f\n\t\t\t\t\t? 0u\n\t\t\t\t\t: Cell.Pack( density, cell.Normal, cell.EntryIndex );\n\n\t\t\t\tchanged = true;\n\t\t\t}\n\t\t}\n\n\t\tif ( !changed )\n\t\t\treturn;\n\n\t\tPruneEmptyChunks();\n\t\tRevision++;\n\t}\n\n\tpublic void ClearAll()\n\t{\n\t\tif ( _chunks.Count == 0 ) return;\n\n\t\t_chunks.Clear();\n\t\tRevision++;\n\t}\n\n\tprivate void PruneEmptyChunks()\n\t{\n\t\tList<ChunkCoord> empty = null;\n\n\t\tforeach ( var (coord, cells) in _chunks )\n\t\t{\n\t\t\tvar used = false;\n\t\t\tfor ( var i = 0; i < cells.Length; i++ )\n\t\t\t{\n\t\t\t\tif ( (cells[i].Packed & 0xFF) != 0 ) { used = true; break; }\n\t\t\t}\n\n\t\t\tif ( !used )\n\t\t\t{\n\t\t\t\tempty ??= [];\n\t\t\t\tempty.Add( coord );\n\t\t\t}\n\t\t}\n\n\t\tif ( empty is null ) return;\n\n\t\tforeach ( var coord in empty )\n\t\t\t_chunks.Remove( coord );\n\t}\n\n\t/// <summary>\n\t/// Writes only the painted cells. Storing them densely cost 8KB per chunk however little of it\n\t/// was painted, and a brush stroke across a landscape touches a lot of chunks.\n\t///\n\t/// Each painted cell costs 2 bytes more than it did dense (its index), so a chunk past about 80%\n\t/// coverage is cheaper stored densely. Both layouts are written and each chunk says which it used.\n\t/// </summary>\n\tpublic override void Serialize( ref Writer writer )\n\t{\n\t\twriter.Stream.Write( _chunks.Count );\n\n\t\tforeach ( var (coord, cells) in _chunks )\n\t\t{\n\t\t\twriter.Stream.Write( coord.X );\n\t\t\twriter.Stream.Write( coord.Y );\n\n\t\t\tvar painted = 0;\n\t\t\tfor ( var i = 0; i < CellsPerChunk; i++ )\n\t\t\t{\n\t\t\t\tif ( (cells[i].Packed & 0xFF) != 0 ) painted++;\n\t\t\t}\n\n\t\t\tvar sparse = painted * 10 < CellsPerChunk * 8;\n\t\t\twriter.Stream.Write( sparse );\n\n\t\t\tif ( !sparse )\n\t\t\t{\n\t\t\t\tfor ( var i = 0; i < CellsPerChunk; i++ )\n\t\t\t\t{\n\t\t\t\t\twriter.Stream.Write( cells[i].Height );\n\t\t\t\t\twriter.Stream.Write( cells[i].Packed );\n\t\t\t\t}\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\twriter.Stream.Write( painted );\n\n\t\t\tfor ( var i = 0; i < CellsPerChunk; i++ )\n\t\t\t{\n\t\t\t\tif ( (cells[i].Packed & 0xFF) == 0 )\n\t\t\t\t\tcontinue;\n\n\t\t\t\twriter.Stream.Write( (ushort)i );\n\t\t\t\twriter.Stream.Write( cells[i].Height );\n\t\t\t\twriter.Stream.Write( cells[i].Packed );\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic override void Deserialize( ref Reader reader )\n\t{\n\t\t_chunks.Clear();\n\n\t\tvar chunkCount = reader.Stream.Read<int>();\n\n\t\tfor ( var c = 0; c < chunkCount; c++ )\n\t\t{\n\t\t\tvar coord = new ChunkCoord( reader.Stream.Read<int>(), reader.Stream.Read<int>() );\n\t\t\tvar cells = new Cell[CellsPerChunk];\n\n\t\t\tif ( reader.Stream.Read<bool>() )\n\t\t\t{\n\t\t\t\tvar painted = reader.Stream.Read<int>();\n\n\t\t\t\tfor ( var p = 0; p < painted; p++ )\n\t\t\t\t{\n\t\t\t\t\tvar index = reader.Stream.Read<ushort>();\n\t\t\t\t\tvar height = reader.Stream.Read<float>();\n\t\t\t\t\tvar packed = reader.Stream.Read<uint>();\n\n\t\t\t\t\tif ( index < CellsPerChunk )\n\t\t\t\t\t{\n\t\t\t\t\t\tcells[index].Height = height;\n\t\t\t\t\t\tcells[index].Packed = packed;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tfor ( var i = 0; i < CellsPerChunk; i++ )\n\t\t\t\t{\n\t\t\t\t\tcells[i].Height = reader.Stream.Read<float>();\n\t\t\t\t\tcells[i].Packed = reader.Stream.Read<uint>();\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t_chunks[coord] = cells;\n\t\t}\n\n\t\tRevision++;\n\t}\n}\n"
},
{
"Ident": "redsnail.floratool",
"Path": ".obj/__compiler_extra.cs",
"FileName": "__compiler_extra.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 343456,
"Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"Flora Tool\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"floratool\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"redsnail\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"redsnail.floratool\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"28\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-08-20T14:01:50.2662074Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.121.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.121.0\")]"
}
]
}