Code/GrassRenderer.cs

A rendering component that generates and draws grass blades on the GPU. It packs painted coverage from GrassStorage into structured GPU buffers, dispatches a compute shader to expand cells into blade instances with append buffers, then issues an indirect instanced draw using a material; it also handles frustum culling, editor integration, and buffer lifecycle.

Native Interop
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Sandbox;
using Sandbox.Rendering;
using RenderStage = Sandbox.Rendering.Stage;

namespace RedSnail.GrassTool;

/// <summary>
/// Renders a painted grass field. Each frame a compute pass expands the density map into blade
/// instances for whatever is near the camera, then a single indirect draw renders them all.
/// No blade ever exists on the CPU, and nothing is stored per-blade in the scene.
/// </summary>
[Icon( "grass" ), Group( "Grass" ), Title( "Grass Renderer" )]
public sealed class GrassRenderer : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer
{
	[StructLayout( LayoutKind.Sequential )]
	private struct GpuChunk
	{
		public Vector2 Origin;
		public int CellOffset;
		public int Pad;
	}

	[StructLayout( LayoutKind.Sequential )]
	private struct GpuBlade
	{
		public Vector3 Position;
		public float Yaw;
		public Vector3 Normal;
		public float Height;
		public float Width;
		public float Tint;
		public float Phase;
		public float Pad; // keeps the struct at 48 bytes
	}

	// Must match GRASS_BLADE_* in grass_shared.fxc.
	private const int BladeStripVerts = 7;
	private const int BladeVertexCount = (BladeStripVerts - 2) * 3;

	// Byte offset of InstanceCount within IndirectDrawArguments. The struct is sequential
	// { uint VertexCount; uint InstanceCount; uint FirstVertex; uint FirstInstance; }, so this is
	// fixed at 4. Marshal.OffsetOf would express it directly but is outside the sandbox whitelist.
	private const int ArgsInstanceCountOffset = 4;

	[Property, Group( "General" )]
	public GrassDefinition Definition { get; set; }

	/// <summary>
	/// Ceiling on blades alive at once. Each costs 48 bytes of GPU memory, so 500k is ~24 MB.
	/// Raise it if dense fields visibly clip out at the far edge of the render distance.
	/// </summary>
	[Property, Group( "General" ), Range( 50000, 4000000 )]
	public int MaxBlades { get; set; } = 500000;

	/// <summary>Painted coverage. Serialized as a binary blob, not JSON.</summary>
	[Property, Hide]
	public GrassStorage Storage { get; set; } = new();

	private ComputeShader _generateShader;
	private Material _material;
	private CommandList _commandList;

	private GpuBuffer<GpuChunk> _chunkBuffer;
	private GpuBuffer<GrassStorage.Cell> _cellBuffer;
	private GpuBuffer<int> _visibleChunkBuffer;
	private GpuBuffer<Vector4> _frustumPlaneBuffer;
	private GpuBuffer<GpuBlade> _bladeBuffer;
	private GpuBuffer<GpuBuffer.IndirectDrawArguments> _argsBuffer;

	private readonly List<Vector2> _chunkOrigins = [];
	private int[] _visibleScratch = [];
	private readonly Vector4[] _planeScratch = new Vector4[6];

	private CameraComponent _lastCamera;
	private int _chunkCount;
	private int _uploadedVersion = -1;
	private int _uploadedMaxBlades = -1;

	protected override void OnEnabled()
	{
		Storage ??= new GrassStorage();

		_generateShader = new ComputeShader("grass_generate_cs");
		_material = Material.FromShader("grass");
		_commandList = new CommandList("Grass Rendering");

		// Force a full re-upload: the buffers were released on disable, so a matching version
		// number here would otherwise leave the compute pass reading freed resources.
		_uploadedVersion = -1;
		_uploadedMaxBlades = -1;

		RefreshBuffers();
	}

	protected override void OnDisabled()
	{
		_lastCamera?.RemoveCommandList( _commandList );
		_lastCamera = null;

		_commandList?.Reset();
		_commandList = null;

		ReleaseBuffers();

		_generateShader = null;
		_material = null;

		_uploadedVersion = -1;
		_uploadedMaxBlades = -1;
	}

	protected override void OnUpdate()
	{
		// Only a scene camera's command list actually replays, in the editor viewport as well as
		// in game. The editor camera never replays one, so it can't own the list.
		var renderCamera = Scene.Camera;

		if ( renderCamera != _lastCamera )
		{
			_lastCamera?.RemoveCommandList( _commandList );
			renderCamera?.AddCommandList( _commandList, RenderStage.AfterOpaque );
			_lastCamera = renderCamera;
		}

		if ( !renderCamera.IsValid() )
			return;

		// ...but culling has to follow whichever camera the viewport is actually looking through,
		// or nothing generates outside the game camera's frustum while you're editing.
		var cullCamera = GetCullCamera();
		if ( !cullCamera.IsValid() )
			return;

		RefreshBuffers();
		RecordCommandList( cullCamera );
	}

	private CameraComponent GetCullCamera()
	{
		if ( Game.IsPlaying )
			return Scene.Camera;

		var editorCamera = Application.Editor?.Camera;
		return editorCamera.IsValid() ? editorCamera : Scene.Camera;
	}

	/// <summary>
	/// Re-packs painted chunks into GPU buffers. Skipped entirely unless the painted data or the
	/// blade budget actually changed.
	/// </summary>
	private void RefreshBuffers()
	{
		if ( Storage is null || Storage.ChunkCount == 0 )
		{
			if ( _chunkCount != 0 )
			{
				ReleaseBuffers();
				_chunkCount = 0;
			}

			_uploadedVersion = Storage?.Revision ?? -1;
			return;
		}

		if ( _uploadedVersion == Storage.Revision && _uploadedMaxBlades == MaxBlades && _chunkBuffer is not null )
			return;

		ReleaseBuffers();

		_chunkCount = Storage.ChunkCount;
		_uploadedVersion = Storage.Revision;
		_uploadedMaxBlades = MaxBlades;

		var chunks = new GpuChunk[_chunkCount];
		var cells = new GrassStorage.Cell[_chunkCount * GrassStorage.CellsPerChunk];

		_chunkOrigins.Clear();

		var index = 0;
		foreach ( var (coord, cellData) in Storage.Chunks )
		{
			var origin = GrassStorage.ChunkOrigin( coord );
			var offset = index * GrassStorage.CellsPerChunk;

			chunks[index] = new GpuChunk { Origin = origin, CellOffset = offset };
			Array.Copy( cellData, 0, cells, offset, GrassStorage.CellsPerChunk );
			_chunkOrigins.Add( origin );

			index++;
		}

		_chunkBuffer = new GpuBuffer<GpuChunk>( _chunkCount, GpuBuffer.UsageFlags.Structured );
		_chunkBuffer.SetData( chunks );

		_cellBuffer = new GpuBuffer<GrassStorage.Cell>( cells.Length, GpuBuffer.UsageFlags.Structured );
		_cellBuffer.SetData( cells );

		_visibleChunkBuffer = new GpuBuffer<int>( _chunkCount, GpuBuffer.UsageFlags.Structured );
		_visibleScratch = new int[_chunkCount];

		_frustumPlaneBuffer = new GpuBuffer<Vector4>( 6, GpuBuffer.UsageFlags.Structured );

		_bladeBuffer = new GpuBuffer<GpuBlade>( MaxBlades, GpuBuffer.UsageFlags.Structured | GpuBuffer.UsageFlags.Append );

		_argsBuffer = new GpuBuffer<GpuBuffer.IndirectDrawArguments>( 1, GpuBuffer.UsageFlags.IndirectDrawArguments );
		_argsBuffer.SetData( new[]
		{
			new GpuBuffer.IndirectDrawArguments { VertexCount = BladeVertexCount }
		} );
	}

	private void RecordCommandList( CameraComponent camera )
	{
		_commandList.Reset();

		if ( _chunkCount == 0 || _bladeBuffer is null )
			return;

		var definition = Definition;
		var cameraPosition = camera.WorldPosition;

		var visibleCount = CollectVisibleChunks( cameraPosition, definition?.MaxDistance ?? 12000.0f );
		if ( visibleCount == 0 )
			return;

		UploadFrustumPlanes( camera );

		_commandList.Attributes.Set( "GrassChunks", _chunkBuffer );
		_commandList.Attributes.Set( "GrassCells", _cellBuffer );
		_commandList.Attributes.Set( "GrassVisibleChunks", _visibleChunkBuffer );
		_commandList.Attributes.Set( "GrassFrustumPlanes", _frustumPlaneBuffer );
		_commandList.Attributes.Set( "GrassBlades", _bladeBuffer );
		_commandList.Attributes.Set( "GrassVisibleChunkCount", visibleCount );
		_commandList.Attributes.Set( "GrassCameraPos", cameraPosition );
		_commandList.Attributes.Set( "GrassTime", RealTime.Now );

		definition?.ApplyTo( _commandList.Attributes );

		_commandList.ResourceBarrierTransition( _bladeBuffer, ResourceState.UnorderedAccess );
		_commandList.SetCounterValue( _bladeBuffer, 0 );

		_commandList.DispatchCompute( _generateShader, visibleCount * GrassStorage.CellsPerChunk, 1, 1 );

		// The appends must all land before the counter is read into the draw arguments.
		_commandList.UavBarrier( _bladeBuffer );

		_commandList.ResourceBarrierTransition( _argsBuffer, ResourceState.CopyDestination );
		_commandList.CopyStructureCount( _bladeBuffer, _argsBuffer, ArgsInstanceCountOffset );

		_commandList.ResourceBarrierTransition( _bladeBuffer, ResourceState.GenericRead );
		_commandList.ResourceBarrierTransition( _argsBuffer, ResourceState.IndirectArgument );

		_commandList.DrawInstancedIndirect( _material, _argsBuffer );
	}

	/// <summary>
	/// Narrows the dispatch to chunks near the camera. Per-blade frustum culling happens on the
	/// GPU; this only has to be conservative.
	/// </summary>
	private int CollectVisibleChunks( Vector3 cameraPosition, float maxDistance )
	{
		// A chunk's near corner can be in range while its centre isn't, hence the circumradius.
		var cullRadius = maxDistance + GrassStorage.ChunkSize * 0.7072f;
		var cullRadiusSq = cullRadius * cullRadius;

		var halfChunk = GrassStorage.ChunkSize * 0.5f;
		var count = 0;

		for ( var i = 0; i < _chunkOrigins.Count; i++ )
		{
			var origin = _chunkOrigins[i];
			var dx = origin.x + halfChunk - cameraPosition.x;
			var dy = origin.y + halfChunk - cameraPosition.y;

			if ( dx * dx + dy * dy > cullRadiusSq )
				continue;

			_visibleScratch[count++] = i;
		}

		if ( count > 0 )
			_visibleChunkBuffer.SetData( _visibleScratch.AsSpan( 0, count ) );

		return count;
	}

	private void UploadFrustumPlanes( CameraComponent camera )
	{
		var frustum = camera.GetFrustum();

		// Plane.GetDistance is dot( point, Normal ) - Distance, so w is negated to let the shader
		// use a plain dot( xyz, p ) + w.
		_planeScratch[0] = ToVector4( frustum.LeftPlane );
		_planeScratch[1] = ToVector4( frustum.RightPlane );
		_planeScratch[2] = ToVector4( frustum.TopPlane );
		_planeScratch[3] = ToVector4( frustum.BottomPlane );
		_planeScratch[4] = ToVector4( frustum.NearPlane );
		_planeScratch[5] = ToVector4( frustum.FarPlane );

		_frustumPlaneBuffer.SetData( _planeScratch );

		static Vector4 ToVector4( Plane plane ) =>
			new( plane.Normal.x, plane.Normal.y, plane.Normal.z, -plane.Distance );
	}

	private void ReleaseBuffers()
	{
		_chunkBuffer?.Dispose();
		_chunkBuffer = null;

		_cellBuffer?.Dispose();
		_cellBuffer = null;

		_visibleChunkBuffer?.Dispose();
		_visibleChunkBuffer = null;

		_frustumPlaneBuffer?.Dispose();
		_frustumPlaneBuffer = null;

		_bladeBuffer?.Dispose();
		_bladeBuffer = null;

		_argsBuffer?.Dispose();
		_argsBuffer = null;

		_chunkOrigins.Clear();
		_chunkCount = 0;
	}

	/// <summary>
	/// Called by the editor tool after painting, so the next frame re-uploads the density map.
	/// </summary>
	public void MarkDirty() => _uploadedVersion = -1;

	protected override void DrawGizmos()
	{
		if ( !Gizmo.IsSelected || Storage is null || Storage.ChunkCount == 0 )
			return;

		Gizmo.Draw.Color = Color.Green.WithAlpha( 0.35f );

		foreach ( var (coord, _) in Storage.Chunks )
		{
			var origin = GrassStorage.ChunkOrigin( coord );
			var mins = new Vector3( origin.x, origin.y, 0 );
			var maxs = new Vector3( origin.x + GrassStorage.ChunkSize, origin.y + GrassStorage.ChunkSize, 0 );

			Gizmo.Draw.LineBBox( new BBox( WorldTransform.PointToLocal( mins ), WorldTransform.PointToLocal( maxs ) ) );
		}
	}
}