GrassStorage.cs

A storage component for sparse painted grass coverage, organized as chunked grids of density samples. It stores Cell structs with height and a packed uint (density and normal), allows setting, getting, erasing, pruning empty chunks, and (de)serializes the chunk dictionary.

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

namespace RedSnail.GrassTool;

/// <summary>
/// Sparse painted grass coverage, stored as a chunked grid of density samples rather than
/// individual blade transforms. Blades are generated procedurally on the GPU from this data,
/// so a square kilometre of dense grass costs a few megabytes instead of hundreds.
/// </summary>
public sealed class GrassStorage : BlobData
{
	public override int Version => 1;

	/// <summary>Cells along one edge of a chunk.</summary>
	public const int ChunkResolution = 64;

	/// <summary>World-space size of a single density cell, in source units.</summary>
	public const float CellSize = 32.0f;

	/// <summary>World-space size of a chunk edge, in source units.</summary>
	public const float ChunkSize = ChunkResolution * CellSize;

	public const int CellsPerChunk = ChunkResolution * ChunkResolution;

	/// <summary>
	/// One density sample. Height and normal are baked when painting so grass sits on any
	/// geometry, not just terrain. Matches the HLSL <c>GrassCell</c> struct exactly.
	/// </summary>
	[StructLayout( LayoutKind.Sequential )]
	public struct Cell
	{
		public float Height;

		/// <summary>density (0-7) | normal.x (8-15) | normal.y (16-23) | unused (24-31)</summary>
		public uint Packed;

		public readonly float Density => (Packed & 0xFF) / 255.0f;

		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 )
		{
			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 );
			return d | (nx << 8) | (ny << 16);
		}
	}

	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 re-upload its GPU buffers.</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 );

	/// <summary>Global cell index on an axis. Negative world positions floor correctly.</summary>
	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 density sample at a world position, 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 )
	{
		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 ) };
		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 density in a radius, removing samples that reach zero.
	/// </summary>
	public void Erase( Vector3 center, float radius, float strength )
	{
		var radiusSq = 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 );

		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 > radiusSq )
					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 );
				Revision++;
			}
		}

		PruneEmptyChunks();
	}

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

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

			for ( var i = 0; i < CellsPerChunk; 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];

			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++;
	}
}