s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

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