🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=redsnail.grasstool&take=20
Showing code results for query:
*
(8 total matches found)
Game
library
using Sandbox;
using Sandbox.Rendering;
namespace RedSnail.GrassTool;
/// <summary>
/// Look and behaviour of one grass layer. Shared by every <see cref="GrassRenderer"/> that
/// references it, so a whole world can be retuned from a single asset.
/// </summary>
[AssetType( Name = "Grass Definition", Extension = "grassdef", Category = "Grass" )]
public sealed class GrassDefinition : GameResource
{
[Property, Group( "Density" ), Range( 1, 32 )]
public int BladesPerCell { get; set; } = 8;
/// <summary>Blades stop generating past this distance from the camera.</summary>
[Property, Group( "Density" ), Range( 500, 60000 )]
public float MaxDistance { get; set; } = 12000.0f;
/// <summary>Distance at which blade count starts thinning out toward <see cref="MaxDistance"/>.</summary>
[Property, Group( "Density" ), Range( 0, 60000 )]
public float FadeStart { get; set; } = 4000.0f;
/// <summary>Minimum ground normal Z. Steeper cells generate nothing, so cliffs stay bare.</summary>
[Property, Group( "Density" ), Range( 0, 1 )]
public float SlopeLimit { get; set; } = 0.5f;
[Property, Group( "Blade" )]
public RangedFloat Height { get; set; } = new( 16.0f, 30.0f );
[Property, Group( "Blade" )]
public RangedFloat Width { get; set; } = new( 1.5f, 2.5f );
/// <summary>How far the blade tip leans forward at rest, as a fraction of its height.</summary>
[Property, Group( "Blade" ), Range( 0, 1 )]
public float Curve { get; set; } = 0.25f;
/// <summary>Blends the blade's up axis from world up (0) toward the ground normal (1).</summary>
[Property, Group( "Blade" ), Range( 0, 1 )]
public float AlignToGround { get; set; } = 0.35f;
[Property, Group( "Color" )]
public Color RootColor { get; set; } = new( 0.11f, 0.20f, 0.06f );
[Property, Group( "Color" )]
public Color TipColor { get; set; } = new( 0.42f, 0.58f, 0.18f );
/// <summary>Per-blade brightness jitter, so a field doesn't read as one flat sheet.</summary>
[Property, Group( "Color" ), Range( 0, 1 )]
public float ColorVariation { get; set; } = 0.25f;
/// <summary>Darkening at the blade root, faking the occlusion of a dense canopy.</summary>
[Property, Group( "Color" ), Range( 0, 1 )]
public float RootOcclusion { get; set; } = 0.4f;
/// <summary>
/// Light bleeding through the blade from a source behind it. This is most of what sells a
/// field backlit by a low sun. Fades out toward the root, where the canopy is dense.
/// </summary>
[Property, Group( "Color" ), Range( 0, 2 )]
public float Transmission { get; set; } = 0.5f;
/// <summary>
/// Softens the lighting terminator. A blade is thin rather than solid, so a hard cutoff at
/// grazing light turns a field into a mass of black edges.
/// </summary>
[Property, Group( "Color" ), Range( 0, 1 )]
public float Wrap { get; set; } = 0.5f;
/// <summary>
/// Pushes the lighting normal toward world up. Grass lit by its true geometric normal reads
/// as a mass of hard black edges; biasing up keeps a field looking like a soft surface.
/// </summary>
[Property, Group( "Color" ), Range( 0, 1 )]
public float NormalUpBias { get; set; } = 0.6f;
[Property, Group( "Wind" )]
public Vector2 WindDirection { get; set; } = new( 1.0f, 0.35f );
[Property, Group( "Wind" ), Range( 0, 4 )]
public float WindStrength { get; set; } = 0.35f;
[Property, Group( "Wind" ), Range( 0, 10 )]
public float WindSpeed { get; set; } = 1.6f;
/// <summary>Spatial frequency of the travelling wind wave. Smaller values give broader gusts.</summary>
[Property, Group( "Wind" )]
public float WindWaveScale { get; set; } = 0.0016f;
public void ApplyTo( CommandList.AttributeAccess attributes )
{
attributes.Set( "GrassBladesPerCell", BladesPerCell );
attributes.Set( "GrassMaxDistance", MaxDistance );
attributes.Set( "GrassFadeStart", MathX.Clamp( FadeStart, 0.0f, MaxDistance - 1.0f ) );
attributes.Set( "GrassSlopeLimit", SlopeLimit );
attributes.Set( "GrassBladeHeight", new Vector2( Height.Min, Height.Max ) );
attributes.Set( "GrassBladeWidth", new Vector2( Width.Min, Width.Max ) );
attributes.Set( "GrassBladeCurve", Curve );
attributes.Set( "GrassAlignToGround", AlignToGround );
attributes.Set( "GrassRootColor", (Vector3)RootColor );
attributes.Set( "GrassTipColor", (Vector3)TipColor );
attributes.Set( "GrassColorVariation", ColorVariation );
attributes.Set( "GrassRootOcclusion", RootOcclusion );
attributes.Set( "GrassTransmission", Transmission );
attributes.Set( "GrassWrap", Wrap );
attributes.Set( "GrassNormalUpBias", NormalUpBias );
attributes.Set( "GrassWindDirection", WindDirection.Normal );
attributes.Set( "GrassWindStrength", WindStrength );
attributes.Set( "GrassWindSpeed", WindSpeed );
attributes.Set( "GrassWindWaveScale", WindWaveScale );
}
protected override Bitmap CreateAssetTypeIcon(int _Width, int _Height)
{
return CreateSimpleAssetTypeIcon("grass", _Width, _Height, "#070f0a", "#7cfba9");
}
}
Game
library
using Sandbox;
using Sandbox.Rendering;
namespace RedSnail.GrassTool;
/// <summary>
/// Look and behaviour of one grass layer. Shared by every <see cref="GrassRenderer"/> that
/// references it, so a whole world can be retuned from a single asset.
/// </summary>
[AssetType( Name = "Grass Definition", Extension = "grassdef", Category = "Grass" )]
public sealed class GrassDefinition : GameResource
{
[Property, Group( "Density" ), Range( 1, 32 )]
public int BladesPerCell { get; set; } = 8;
/// <summary>Blades stop generating past this distance from the camera.</summary>
[Property, Group( "Density" ), Range( 500, 60000 )]
public float MaxDistance { get; set; } = 12000.0f;
/// <summary>Distance at which blade count starts thinning out toward <see cref="MaxDistance"/>.</summary>
[Property, Group( "Density" ), Range( 0, 60000 )]
public float FadeStart { get; set; } = 4000.0f;
/// <summary>Minimum ground normal Z. Steeper cells generate nothing, so cliffs stay bare.</summary>
[Property, Group( "Density" ), Range( 0, 1 )]
public float SlopeLimit { get; set; } = 0.5f;
[Property, Group( "Blade" )]
public RangedFloat Height { get; set; } = new( 16.0f, 30.0f );
[Property, Group( "Blade" )]
public RangedFloat Width { get; set; } = new( 1.5f, 2.5f );
/// <summary>How far the blade tip leans forward at rest, as a fraction of its height.</summary>
[Property, Group( "Blade" ), Range( 0, 1 )]
public float Curve { get; set; } = 0.25f;
/// <summary>Blends the blade's up axis from world up (0) toward the ground normal (1).</summary>
[Property, Group( "Blade" ), Range( 0, 1 )]
public float AlignToGround { get; set; } = 0.35f;
[Property, Group( "Color" )]
public Color RootColor { get; set; } = new( 0.11f, 0.20f, 0.06f );
[Property, Group( "Color" )]
public Color TipColor { get; set; } = new( 0.42f, 0.58f, 0.18f );
/// <summary>Per-blade brightness jitter, so a field doesn't read as one flat sheet.</summary>
[Property, Group( "Color" ), Range( 0, 1 )]
public float ColorVariation { get; set; } = 0.25f;
/// <summary>Darkening at the blade root, faking the occlusion of a dense canopy.</summary>
[Property, Group( "Color" ), Range( 0, 1 )]
public float RootOcclusion { get; set; } = 0.4f;
/// <summary>
/// Light bleeding through the blade from a source behind it. This is most of what sells a
/// field backlit by a low sun. Fades out toward the root, where the canopy is dense.
/// </summary>
[Property, Group( "Color" ), Range( 0, 2 )]
public float Transmission { get; set; } = 0.5f;
/// <summary>
/// Softens the lighting terminator. A blade is thin rather than solid, so a hard cutoff at
/// grazing light turns a field into a mass of black edges.
/// </summary>
[Property, Group( "Color" ), Range( 0, 1 )]
public float Wrap { get; set; } = 0.5f;
/// <summary>
/// Pushes the lighting normal toward world up. Grass lit by its true geometric normal reads
/// as a mass of hard black edges; biasing up keeps a field looking like a soft surface.
/// </summary>
[Property, Group( "Color" ), Range( 0, 1 )]
public float NormalUpBias { get; set; } = 0.6f;
[Property, Group( "Wind" )]
public Vector2 WindDirection { get; set; } = new( 1.0f, 0.35f );
[Property, Group( "Wind" ), Range( 0, 4 )]
public float WindStrength { get; set; } = 0.35f;
[Property, Group( "Wind" ), Range( 0, 10 )]
public float WindSpeed { get; set; } = 1.6f;
/// <summary>Spatial frequency of the travelling wind wave. Smaller values give broader gusts.</summary>
[Property, Group( "Wind" )]
public float WindWaveScale { get; set; } = 0.0016f;
public void ApplyTo( CommandList.AttributeAccess attributes )
{
attributes.Set( "GrassBladesPerCell", BladesPerCell );
attributes.Set( "GrassMaxDistance", MaxDistance );
attributes.Set( "GrassFadeStart", MathX.Clamp( FadeStart, 0.0f, MaxDistance - 1.0f ) );
attributes.Set( "GrassSlopeLimit", SlopeLimit );
attributes.Set( "GrassBladeHeight", new Vector2( Height.Min, Height.Max ) );
attributes.Set( "GrassBladeWidth", new Vector2( Width.Min, Width.Max ) );
attributes.Set( "GrassBladeCurve", Curve );
attributes.Set( "GrassAlignToGround", AlignToGround );
attributes.Set( "GrassRootColor", (Vector3)RootColor );
attributes.Set( "GrassTipColor", (Vector3)TipColor );
attributes.Set( "GrassColorVariation", ColorVariation );
attributes.Set( "GrassRootOcclusion", RootOcclusion );
attributes.Set( "GrassTransmission", Transmission );
attributes.Set( "GrassWrap", Wrap );
attributes.Set( "GrassNormalUpBias", NormalUpBias );
attributes.Set( "GrassWindDirection", WindDirection.Normal );
attributes.Set( "GrassWindStrength", WindStrength );
attributes.Set( "GrassWindSpeed", WindSpeed );
attributes.Set( "GrassWindWaveScale", WindWaveScale );
}
protected override Bitmap CreateAssetTypeIcon(int _Width, int _Height)
{
return CreateSimpleAssetTypeIcon("grass", _Width, _Height, "#070f0a", "#7cfba9");
}
}
Game
library
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++;
}
}
Game
library
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>
/// How far outside the camera frustum blades are still generated, in world units. Culling is
/// done against the frustum as it was when the frame started, so turning the camera brings in
/// blades that were never generated. Raise this if they visibly stream in at the screen edges,
/// which is most obvious at low or capped framerates.
/// </summary>
[Property, Group( "General" ), Range( 0, 4000 )]
public float CullPadding { get; set; } = 1000.0f;
/// <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()
{
var renderCamera = GetRenderCamera();
// Re-attach when the camera changes, and also when the one we attached to stopped being
// valid. Leaving play mode destroys the play camera without the reference here changing,
// so comparing references alone would leave us bound to a dead camera forever.
if (renderCamera != _lastCamera || !_lastCamera.IsValid())
{
if (_lastCamera.IsValid())
_lastCamera.RemoveCommandList(_commandList);
_lastCamera = null;
if (renderCamera.IsValid())
{
renderCamera.AddCommandList(_commandList, RenderStage.AfterOpaque);
_lastCamera = renderCamera;
}
}
// Nothing to draw through until a camera exists. State is cleared above, so we pick one
// up as soon as one appears.
if (!_lastCamera.IsValid())
return;
// Culling follows whichever camera the viewport actually looks through, which is not
// necessarily the one replaying the list.
var cullCamera = GetCullCamera();
if (!cullCamera.IsValid())
return;
RefreshBuffers();
RecordCommandList(cullCamera);
}
/// <summary>
/// The camera whose command list actually replays. A scene camera does so in the editor
/// viewport as well as in game, so it wins when one exists; with an empty scene the editor
/// camera is the only thing left that will replay ours.
/// </summary>
private CameraComponent GetRenderCamera()
{
if (Scene.Camera.IsValid())
return Scene.Camera;
if (Scene.IsEditor)
return Application.Editor?.Camera;
return null;
}
/// <summary>
/// The camera the blades are generated for. While editing this is the viewport camera, or
/// nothing outside the game camera's frustum would ever be generated.
/// </summary>
private CameraComponent GetCullCamera()
{
if (Scene.IsEditor)
{
var editorCamera = Application.Editor?.Camera;
if (editorCamera.IsValid())
return editorCamera;
}
return 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();
// These planes are sampled once on the CPU but the blades they cull are not drawn until the
// frame presents, by which point the camera has kept turning. Pushing every plane outward
// gives the generation something to work with at the screen edges - without it, blades
// rotating into view were culled before they were ever needed, which reads as them
// streaming in from the sides. The slack that buys scales with the frame time, so a capped
// or struggling framerate is exactly when it matters most.
var padding = MathF.Max( CullPadding, 0.0f );
// Plane.GetDistance is dot( point, Normal ) - Distance, so w is negated to let the shader
// use a plain dot( xyz, p ) + w. Adding the padding there slides the plane outward.
_planeScratch[0] = ToVector4( frustum.LeftPlane, padding );
_planeScratch[1] = ToVector4( frustum.RightPlane, padding );
_planeScratch[2] = ToVector4( frustum.TopPlane, padding );
_planeScratch[3] = ToVector4( frustum.BottomPlane, padding );
_planeScratch[4] = ToVector4( frustum.NearPlane, padding );
// The far plane is left tight - MaxDistance already governs the far edge, and padding it
// would only generate blades that fade out before they are ever visible.
_planeScratch[5] = ToVector4( frustum.FarPlane, 0.0f );
_frustumPlaneBuffer.SetData( _planeScratch );
static Vector4 ToVector4( Plane plane, float padding ) =>
new( plane.Normal.x, plane.Normal.y, plane.Normal.z, -plane.Distance + padding );
}
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 ) ) );
}
}
}
Game
library
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++;
}
}
Game
library
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>
/// How far outside the camera frustum blades are still generated, in world units. Culling is
/// done against the frustum as it was when the frame started, so turning the camera brings in
/// blades that were never generated. Raise this if they visibly stream in at the screen edges,
/// which is most obvious at low or capped framerates.
/// </summary>
[Property, Group( "General" ), Range( 0, 4000 )]
public float CullPadding { get; set; } = 1000.0f;
/// <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()
{
var renderCamera = GetRenderCamera();
// Re-attach when the camera changes, and also when the one we attached to stopped being
// valid. Leaving play mode destroys the play camera without the reference here changing,
// so comparing references alone would leave us bound to a dead camera forever.
if (renderCamera != _lastCamera || !_lastCamera.IsValid())
{
if (_lastCamera.IsValid())
_lastCamera.RemoveCommandList(_commandList);
_lastCamera = null;
if (renderCamera.IsValid())
{
renderCamera.AddCommandList(_commandList, RenderStage.AfterOpaque);
_lastCamera = renderCamera;
}
}
// Nothing to draw through until a camera exists. State is cleared above, so we pick one
// up as soon as one appears.
if (!_lastCamera.IsValid())
return;
// Culling follows whichever camera the viewport actually looks through, which is not
// necessarily the one replaying the list.
var cullCamera = GetCullCamera();
if (!cullCamera.IsValid())
return;
RefreshBuffers();
RecordCommandList(cullCamera);
}
/// <summary>
/// The camera whose command list actually replays. A scene camera does so in the editor
/// viewport as well as in game, so it wins when one exists; with an empty scene the editor
/// camera is the only thing left that will replay ours.
/// </summary>
private CameraComponent GetRenderCamera()
{
if (Scene.Camera.IsValid())
return Scene.Camera;
if (Scene.IsEditor)
return Application.Editor?.Camera;
return null;
}
/// <summary>
/// The camera the blades are generated for. While editing this is the viewport camera, or
/// nothing outside the game camera's frustum would ever be generated.
/// </summary>
private CameraComponent GetCullCamera()
{
if (Scene.IsEditor)
{
var editorCamera = Application.Editor?.Camera;
if (editorCamera.IsValid())
return editorCamera;
}
return 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();
// These planes are sampled once on the CPU but the blades they cull are not drawn until the
// frame presents, by which point the camera has kept turning. Pushing every plane outward
// gives the generation something to work with at the screen edges - without it, blades
// rotating into view were culled before they were ever needed, which reads as them
// streaming in from the sides. The slack that buys scales with the frame time, so a capped
// or struggling framerate is exactly when it matters most.
var padding = MathF.Max( CullPadding, 0.0f );
// Plane.GetDistance is dot( point, Normal ) - Distance, so w is negated to let the shader
// use a plain dot( xyz, p ) + w. Adding the padding there slides the plane outward.
_planeScratch[0] = ToVector4( frustum.LeftPlane, padding );
_planeScratch[1] = ToVector4( frustum.RightPlane, padding );
_planeScratch[2] = ToVector4( frustum.TopPlane, padding );
_planeScratch[3] = ToVector4( frustum.BottomPlane, padding );
_planeScratch[4] = ToVector4( frustum.NearPlane, padding );
// The far plane is left tight - MaxDistance already governs the far edge, and padding it
// would only generate blades that fade out before they are ever visible.
_planeScratch[5] = ToVector4( frustum.FarPlane, 0.0f );
_frustumPlaneBuffer.SetData( _planeScratch );
static Vector4 ToVector4( Plane plane, float padding ) =>
new( plane.Normal.x, plane.Normal.y, plane.Normal.z, -plane.Distance + padding );
}
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 ) ) );
}
}
}
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", "Grass Tool" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "grasstool" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "redsnail" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "redsnail.grasstool" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "28" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-08-12T23:58:53.3546626Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.137.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.137.0")]
Editor
library
using System;
using System.Linq;
using Editor;
using Editor.TerrainEditor;
using Sandbox;
namespace RedSnail.GrassTool.Editor;
/// <summary>
/// Paints grass coverage onto any surface. The brush writes density into the target
/// <see cref="GrassRenderer"/>'s density map, baking the surface height and normal under each
/// cell so blades sit on whatever geometry is there. Hold Ctrl to erase.
/// </summary>
[EditorTool( "grass" )]
[Title( "Grass" )]
[Icon( "grass" )]
public sealed class GrassPaintTool : EditorTool
{
public BrushSettings BrushSettings { get; private set; } = new();
private GrassRenderer _target;
private bool _erasing;
private bool _dragging;
private bool _painted;
private Vector3 _lastPaintPosition;
// Repainting the same spot every frame just burns traces, so the brush has to travel a
// fraction of its own radius before it deposits again.
private float PaintStepDistance => BrushSettings.Size * 0.25f;
public GrassPaintTool()
{
RebuildSidebarOnSelectionChange = false;
}
public override Widget CreateToolSidebar()
{
var sidebar = new ToolSidebarWidget();
sidebar.AddTitle( "Grass 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 ) ) ) );
}
{
var group = sidebar.AddGroup( "Actions" );
var clear = new Button( "Clear All Grass", "delete_sweep" );
clear.ToolTip = "Remove every painted cell from the target Grass Renderer";
clear.Clicked += () =>
{
var target = ResolveTarget();
if ( !target.IsValid() || target.Storage is null )
return;
// Wiping the density map throws away every stroke and there is no undo for it,
// so this one gets a confirmation.
Dialog.AskConfirm(
() =>
{
target.Storage.ClearAll();
target.MarkDirty();
},
"Are you sure you want to delete all grass? This action cannot be undone.",
"Delete All Grass",
"Delete",
"Cancel" );
};
group.Add( clear );
}
// Soaks up the leftover height. Without it the column spreads the groups out to fill the
// panel instead of stacking them at the top.
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 if there is one, otherwise the only one in the scene. Creating
/// it implicitly would leave stray components around every time someone opens the tool.
/// </summary>
private GrassRenderer ResolveTarget()
{
var selected = Selection
.OfType<GameObject>()
.Select( go => go.Components.Get<GrassRenderer>( FindMode.EnabledInSelfAndDescendants ) )
.FirstOrDefault( r => r.IsValid() );
if ( selected.IsValid() )
{
_target = selected;
return _target;
}
if ( _target.IsValid() )
return _target;
_target = Scene.GetAllComponents<GrassRenderer>().FirstOrDefault();
return _target;
}
private void OnPaintUpdate()
{
var target = ResolveTarget();
if ( !target.IsValid() || target.Storage is null )
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;
}
PaintCells( target, cursor.HitPosition, radius, strength );
_painted = true;
}
/// <summary>
/// Walks every density cell the brush touches and traces straight down onto the world to bake
/// the surface height and normal. Blades then follow whatever they were painted onto.
/// </summary>
private void PaintCells( GrassRenderer target, Vector3 center, float radius, float strength )
{
var radiusSq = radius * radius;
var minX = (int)MathF.Floor( (center.x - radius) / GrassStorage.CellSize );
var maxX = (int)MathF.Floor( (center.x + radius) / GrassStorage.CellSize );
var minY = (int)MathF.Floor( (center.y - radius) / GrassStorage.CellSize );
var maxY = (int)MathF.Floor( (center.y + radius) / GrassStorage.CellSize );
// Enough headroom to find the surface from above without punching through overhangs the
// brush was never aimed at.
var traceHeight = radius + 512.0f;
for ( var cy = minY; cy <= maxY; cy++ )
{
for ( var cx = minX; cx <= maxX; cx++ )
{
var wx = (cx + 0.5f) * GrassStorage.CellSize;
var wy = (cy + 0.5f) * GrassStorage.CellSize;
var dx = wx - center.x;
var dy = wy - center.y;
var distSq = dx * dx + dy * dy;
if ( distSq > radiusSq )
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;
// 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 = target.Storage.GetCell( wx, wy ).Density;
var density = Math.Clamp( existing + added, 0.0f, 1.0f );
target.Storage.SetCell( wx, wy, density, tr.HitPosition.z, tr.Normal );
}
}
}
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( "GrassBrush" ) )
{
Gizmo.Draw.Color = _erasing
? Color.FromBytes( 250, 150, 150 )
: Color.FromBytes( 150, 250, 160 );
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 );
}
}
}
Debug: View Raw JSON Response
{
"TotalCount": 8,
"Files": [
{
"Ident": "redsnail.grasstool",
"Path": "Code/GrassDefinition.cs",
"FileName": "GrassDefinition.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 341332,
"Code": "using Sandbox;\nusing Sandbox.Rendering;\n\nnamespace RedSnail.GrassTool;\n\n/// <summary>\n/// Look and behaviour of one grass layer. Shared by every <see cref=\"GrassRenderer\"/> that\n/// references it, so a whole world can be retuned from a single asset.\n/// </summary>\n[AssetType( Name = \"Grass Definition\", Extension = \"grassdef\", Category = \"Grass\" )]\npublic sealed class GrassDefinition : GameResource\n{\n\t[Property, Group( \"Density\" ), Range( 1, 32 )]\n\tpublic int BladesPerCell { get; set; } = 8;\n\n\t/// <summary>Blades stop generating past this distance from the camera.</summary>\n\t[Property, Group( \"Density\" ), Range( 500, 60000 )]\n\tpublic float MaxDistance { get; set; } = 12000.0f;\n\n\t/// <summary>Distance at which blade count starts thinning out toward <see cref=\"MaxDistance\"/>.</summary>\n\t[Property, Group( \"Density\" ), Range( 0, 60000 )]\n\tpublic float FadeStart { get; set; } = 4000.0f;\n\n\t/// <summary>Minimum ground normal Z. Steeper cells generate nothing, so cliffs stay bare.</summary>\n\t[Property, Group( \"Density\" ), Range( 0, 1 )]\n\tpublic float SlopeLimit { get; set; } = 0.5f;\n\n\t[Property, Group( \"Blade\" )]\n\tpublic RangedFloat Height { get; set; } = new( 16.0f, 30.0f );\n\n\t[Property, Group( \"Blade\" )]\n\tpublic RangedFloat Width { get; set; } = new( 1.5f, 2.5f );\n\n\t/// <summary>How far the blade tip leans forward at rest, as a fraction of its height.</summary>\n\t[Property, Group( \"Blade\" ), Range( 0, 1 )]\n\tpublic float Curve { get; set; } = 0.25f;\n\n\t/// <summary>Blends the blade's up axis from world up (0) toward the ground normal (1).</summary>\n\t[Property, Group( \"Blade\" ), Range( 0, 1 )]\n\tpublic float AlignToGround { get; set; } = 0.35f;\n\n\t[Property, Group( \"Color\" )]\n\tpublic Color RootColor { get; set; } = new( 0.11f, 0.20f, 0.06f );\n\n\t[Property, Group( \"Color\" )]\n\tpublic Color TipColor { get; set; } = new( 0.42f, 0.58f, 0.18f );\n\n\t/// <summary>Per-blade brightness jitter, so a field doesn't read as one flat sheet.</summary>\n\t[Property, Group( \"Color\" ), Range( 0, 1 )]\n\tpublic float ColorVariation { get; set; } = 0.25f;\n\n\t/// <summary>Darkening at the blade root, faking the occlusion of a dense canopy.</summary>\n\t[Property, Group( \"Color\" ), Range( 0, 1 )]\n\tpublic float RootOcclusion { get; set; } = 0.4f;\n\n\t/// <summary>\n\t/// Light bleeding through the blade from a source behind it. This is most of what sells a\n\t/// field backlit by a low sun. Fades out toward the root, where the canopy is dense.\n\t/// </summary>\n\t[Property, Group( \"Color\" ), Range( 0, 2 )]\n\tpublic float Transmission { get; set; } = 0.5f;\n\n\t/// <summary>\n\t/// Softens the lighting terminator. A blade is thin rather than solid, so a hard cutoff at\n\t/// grazing light turns a field into a mass of black edges.\n\t/// </summary>\n\t[Property, Group( \"Color\" ), Range( 0, 1 )]\n\tpublic float Wrap { get; set; } = 0.5f;\n\n\t/// <summary>\n\t/// Pushes the lighting normal toward world up. Grass lit by its true geometric normal reads\n\t/// as a mass of hard black edges; biasing up keeps a field looking like a soft surface.\n\t/// </summary>\n\t[Property, Group( \"Color\" ), Range( 0, 1 )]\n\tpublic float NormalUpBias { get; set; } = 0.6f;\n\n\t[Property, Group( \"Wind\" )]\n\tpublic Vector2 WindDirection { get; set; } = new( 1.0f, 0.35f );\n\n\t[Property, Group( \"Wind\" ), Range( 0, 4 )]\n\tpublic float WindStrength { get; set; } = 0.35f;\n\n\t[Property, Group( \"Wind\" ), Range( 0, 10 )]\n\tpublic float WindSpeed { get; set; } = 1.6f;\n\n\t/// <summary>Spatial frequency of the travelling wind wave. Smaller values give broader gusts.</summary>\n\t[Property, Group( \"Wind\" )]\n\tpublic float WindWaveScale { get; set; } = 0.0016f;\n\n\tpublic void ApplyTo( CommandList.AttributeAccess attributes )\n\t{\n\t\tattributes.Set( \"GrassBladesPerCell\", BladesPerCell );\n\t\tattributes.Set( \"GrassMaxDistance\", MaxDistance );\n\t\tattributes.Set( \"GrassFadeStart\", MathX.Clamp( FadeStart, 0.0f, MaxDistance - 1.0f ) );\n\t\tattributes.Set( \"GrassSlopeLimit\", SlopeLimit );\n\n\t\tattributes.Set( \"GrassBladeHeight\", new Vector2( Height.Min, Height.Max ) );\n\t\tattributes.Set( \"GrassBladeWidth\", new Vector2( Width.Min, Width.Max ) );\n\t\tattributes.Set( \"GrassBladeCurve\", Curve );\n\t\tattributes.Set( \"GrassAlignToGround\", AlignToGround );\n\n\t\tattributes.Set( \"GrassRootColor\", (Vector3)RootColor );\n\t\tattributes.Set( \"GrassTipColor\", (Vector3)TipColor );\n\t\tattributes.Set( \"GrassColorVariation\", ColorVariation );\n\t\tattributes.Set( \"GrassRootOcclusion\", RootOcclusion );\n\t\tattributes.Set( \"GrassTransmission\", Transmission );\n\t\tattributes.Set( \"GrassWrap\", Wrap );\n\t\tattributes.Set( \"GrassNormalUpBias\", NormalUpBias );\n\n\t\tattributes.Set( \"GrassWindDirection\", WindDirection.Normal );\n\t\tattributes.Set( \"GrassWindStrength\", WindStrength );\n\t\tattributes.Set( \"GrassWindSpeed\", WindSpeed );\n\t\tattributes.Set( \"GrassWindWaveScale\", WindWaveScale );\n\t}\n\t\n\tprotected override Bitmap CreateAssetTypeIcon(int _Width, int _Height)\n\t{\n\t\treturn CreateSimpleAssetTypeIcon(\"grass\", _Width, _Height, \"#070f0a\", \"#7cfba9\");\n\t}\n}\n"
},
{
"Ident": "redsnail.grasstool",
"Path": "GrassDefinition.cs",
"FileName": "GrassDefinition.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 341332,
"Code": "using Sandbox;\nusing Sandbox.Rendering;\n\nnamespace RedSnail.GrassTool;\n\n/// <summary>\n/// Look and behaviour of one grass layer. Shared by every <see cref=\"GrassRenderer\"/> that\n/// references it, so a whole world can be retuned from a single asset.\n/// </summary>\n[AssetType( Name = \"Grass Definition\", Extension = \"grassdef\", Category = \"Grass\" )]\npublic sealed class GrassDefinition : GameResource\n{\n\t[Property, Group( \"Density\" ), Range( 1, 32 )]\n\tpublic int BladesPerCell { get; set; } = 8;\n\n\t/// <summary>Blades stop generating past this distance from the camera.</summary>\n\t[Property, Group( \"Density\" ), Range( 500, 60000 )]\n\tpublic float MaxDistance { get; set; } = 12000.0f;\n\n\t/// <summary>Distance at which blade count starts thinning out toward <see cref=\"MaxDistance\"/>.</summary>\n\t[Property, Group( \"Density\" ), Range( 0, 60000 )]\n\tpublic float FadeStart { get; set; } = 4000.0f;\n\n\t/// <summary>Minimum ground normal Z. Steeper cells generate nothing, so cliffs stay bare.</summary>\n\t[Property, Group( \"Density\" ), Range( 0, 1 )]\n\tpublic float SlopeLimit { get; set; } = 0.5f;\n\n\t[Property, Group( \"Blade\" )]\n\tpublic RangedFloat Height { get; set; } = new( 16.0f, 30.0f );\n\n\t[Property, Group( \"Blade\" )]\n\tpublic RangedFloat Width { get; set; } = new( 1.5f, 2.5f );\n\n\t/// <summary>How far the blade tip leans forward at rest, as a fraction of its height.</summary>\n\t[Property, Group( \"Blade\" ), Range( 0, 1 )]\n\tpublic float Curve { get; set; } = 0.25f;\n\n\t/// <summary>Blends the blade's up axis from world up (0) toward the ground normal (1).</summary>\n\t[Property, Group( \"Blade\" ), Range( 0, 1 )]\n\tpublic float AlignToGround { get; set; } = 0.35f;\n\n\t[Property, Group( \"Color\" )]\n\tpublic Color RootColor { get; set; } = new( 0.11f, 0.20f, 0.06f );\n\n\t[Property, Group( \"Color\" )]\n\tpublic Color TipColor { get; set; } = new( 0.42f, 0.58f, 0.18f );\n\n\t/// <summary>Per-blade brightness jitter, so a field doesn't read as one flat sheet.</summary>\n\t[Property, Group( \"Color\" ), Range( 0, 1 )]\n\tpublic float ColorVariation { get; set; } = 0.25f;\n\n\t/// <summary>Darkening at the blade root, faking the occlusion of a dense canopy.</summary>\n\t[Property, Group( \"Color\" ), Range( 0, 1 )]\n\tpublic float RootOcclusion { get; set; } = 0.4f;\n\n\t/// <summary>\n\t/// Light bleeding through the blade from a source behind it. This is most of what sells a\n\t/// field backlit by a low sun. Fades out toward the root, where the canopy is dense.\n\t/// </summary>\n\t[Property, Group( \"Color\" ), Range( 0, 2 )]\n\tpublic float Transmission { get; set; } = 0.5f;\n\n\t/// <summary>\n\t/// Softens the lighting terminator. A blade is thin rather than solid, so a hard cutoff at\n\t/// grazing light turns a field into a mass of black edges.\n\t/// </summary>\n\t[Property, Group( \"Color\" ), Range( 0, 1 )]\n\tpublic float Wrap { get; set; } = 0.5f;\n\n\t/// <summary>\n\t/// Pushes the lighting normal toward world up. Grass lit by its true geometric normal reads\n\t/// as a mass of hard black edges; biasing up keeps a field looking like a soft surface.\n\t/// </summary>\n\t[Property, Group( \"Color\" ), Range( 0, 1 )]\n\tpublic float NormalUpBias { get; set; } = 0.6f;\n\n\t[Property, Group( \"Wind\" )]\n\tpublic Vector2 WindDirection { get; set; } = new( 1.0f, 0.35f );\n\n\t[Property, Group( \"Wind\" ), Range( 0, 4 )]\n\tpublic float WindStrength { get; set; } = 0.35f;\n\n\t[Property, Group( \"Wind\" ), Range( 0, 10 )]\n\tpublic float WindSpeed { get; set; } = 1.6f;\n\n\t/// <summary>Spatial frequency of the travelling wind wave. Smaller values give broader gusts.</summary>\n\t[Property, Group( \"Wind\" )]\n\tpublic float WindWaveScale { get; set; } = 0.0016f;\n\n\tpublic void ApplyTo( CommandList.AttributeAccess attributes )\n\t{\n\t\tattributes.Set( \"GrassBladesPerCell\", BladesPerCell );\n\t\tattributes.Set( \"GrassMaxDistance\", MaxDistance );\n\t\tattributes.Set( \"GrassFadeStart\", MathX.Clamp( FadeStart, 0.0f, MaxDistance - 1.0f ) );\n\t\tattributes.Set( \"GrassSlopeLimit\", SlopeLimit );\n\n\t\tattributes.Set( \"GrassBladeHeight\", new Vector2( Height.Min, Height.Max ) );\n\t\tattributes.Set( \"GrassBladeWidth\", new Vector2( Width.Min, Width.Max ) );\n\t\tattributes.Set( \"GrassBladeCurve\", Curve );\n\t\tattributes.Set( \"GrassAlignToGround\", AlignToGround );\n\n\t\tattributes.Set( \"GrassRootColor\", (Vector3)RootColor );\n\t\tattributes.Set( \"GrassTipColor\", (Vector3)TipColor );\n\t\tattributes.Set( \"GrassColorVariation\", ColorVariation );\n\t\tattributes.Set( \"GrassRootOcclusion\", RootOcclusion );\n\t\tattributes.Set( \"GrassTransmission\", Transmission );\n\t\tattributes.Set( \"GrassWrap\", Wrap );\n\t\tattributes.Set( \"GrassNormalUpBias\", NormalUpBias );\n\n\t\tattributes.Set( \"GrassWindDirection\", WindDirection.Normal );\n\t\tattributes.Set( \"GrassWindStrength\", WindStrength );\n\t\tattributes.Set( \"GrassWindSpeed\", WindSpeed );\n\t\tattributes.Set( \"GrassWindWaveScale\", WindWaveScale );\n\t}\n\t\n\tprotected override Bitmap CreateAssetTypeIcon(int _Width, int _Height)\n\t{\n\t\treturn CreateSimpleAssetTypeIcon(\"grass\", _Width, _Height, \"#070f0a\", \"#7cfba9\");\n\t}\n}\n"
},
{
"Ident": "redsnail.grasstool",
"Path": "GrassStorage.cs",
"FileName": "GrassStorage.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 341332,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Runtime.InteropServices;\nusing Sandbox;\n\nnamespace RedSnail.GrassTool;\n\n/// <summary>\n/// Sparse painted grass coverage, stored as a chunked grid of density samples rather than\n/// individual blade transforms. Blades are generated procedurally on the GPU from this data,\n/// so a square kilometre of dense grass costs a few megabytes instead of hundreds.\n/// </summary>\npublic sealed class GrassStorage : BlobData\n{\n\tpublic override int Version => 1;\n\n\t/// <summary>Cells along one edge of a chunk.</summary>\n\tpublic const int ChunkResolution = 64;\n\n\t/// <summary>World-space size of a single density cell, in source units.</summary>\n\tpublic const float CellSize = 32.0f;\n\n\t/// <summary>World-space size of a chunk edge, in source units.</summary>\n\tpublic const float ChunkSize = ChunkResolution * CellSize;\n\n\tpublic const int CellsPerChunk = ChunkResolution * ChunkResolution;\n\n\t/// <summary>\n\t/// One density sample. Height and normal are baked when painting so grass sits on any\n\t/// geometry, not just terrain. Matches the HLSL <c>GrassCell</c> struct exactly.\n\t/// </summary>\n\t[StructLayout( LayoutKind.Sequential )]\n\tpublic struct Cell\n\t{\n\t\tpublic float Height;\n\n\t\t/// <summary>density (0-7) | normal.x (8-15) | normal.y (16-23) | unused (24-31)</summary>\n\t\tpublic uint Packed;\n\n\t\tpublic readonly float Density => (Packed & 0xFF) / 255.0f;\n\n\t\tpublic readonly Vector3 Normal\n\t\t{\n\t\t\tget\n\t\t\t{\n\t\t\t\tvar x = ((Packed >> 8) & 0xFF) / 127.5f - 1.0f;\n\t\t\t\tvar y = ((Packed >> 16) & 0xFF) / 127.5f - 1.0f;\n\t\t\t\tvar z = MathF.Sqrt( Math.Clamp( 1.0f - x * x - y * y, 0.0f, 1.0f ) );\n\t\t\t\treturn new Vector3( x, y, z );\n\t\t\t}\n\t\t}\n\n\t\tpublic static uint Pack( float density, Vector3 normal )\n\t\t{\n\t\t\tvar d = (uint)Math.Clamp( density * 255.0f + 0.5f, 0.0f, 255.0f );\n\t\t\tvar nx = (uint)Math.Clamp( (normal.x + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );\n\t\t\tvar ny = (uint)Math.Clamp( (normal.y + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );\n\t\t\treturn d | (nx << 8) | (ny << 16);\n\t\t}\n\t}\n\n\tpublic readonly record struct ChunkCoord( int X, int Y );\n\n\tprivate readonly Dictionary<ChunkCoord, Cell[]> _chunks = [];\n\n\t/// <summary>Bumped on every mutation so the renderer knows to re-upload its GPU buffers.</summary>\n\tpublic int Revision { get; private set; }\n\n\tpublic int ChunkCount => _chunks.Count;\n\n\tpublic IReadOnlyDictionary<ChunkCoord, Cell[]> Chunks => _chunks;\n\n\tpublic static ChunkCoord WorldToChunk( Vector3 world ) => new(\n\t\t(int)MathF.Floor( world.x / ChunkSize ),\n\t\t(int)MathF.Floor( world.y / ChunkSize ) );\n\n\tpublic static Vector2 ChunkOrigin( ChunkCoord coord ) => new( coord.X * ChunkSize, coord.Y * ChunkSize );\n\n\t/// <summary>Global cell index on an axis. Negative world positions floor correctly.</summary>\n\tprivate static int WorldToCell( float world ) => (int)MathF.Floor( world / CellSize );\n\n\tprivate static int FloorDiv( int a, int b ) => a >= 0 ? a / b : ~(~a / b);\n\n\tprivate static int Mod( int a, int b )\n\t{\n\t\tvar r = a % b;\n\t\treturn r < 0 ? r + b : r;\n\t}\n\n\t/// <summary>\n\t/// Writes a density sample at a world position, baking the surface height and normal alongside it.\n\t/// Density of zero frees the sample.\n\t/// </summary>\n\tpublic void SetCell( float worldX, float worldY, float density, float height, Vector3 normal )\n\t{\n\t\tvar cellX = WorldToCell( worldX );\n\t\tvar cellY = WorldToCell( worldY );\n\t\tvar coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );\n\n\t\tif ( !_chunks.TryGetValue( coord, out var cells ) )\n\t\t{\n\t\t\tif ( density <= 0.0f ) return;\n\n\t\t\tcells = new Cell[CellsPerChunk];\n\t\t\t_chunks[coord] = cells;\n\t\t}\n\n\t\tvar index = Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution );\n\t\tcells[index] = new Cell { Height = height, Packed = Cell.Pack( density, normal ) };\n\t\tRevision++;\n\t}\n\n\tpublic Cell GetCell( float worldX, float worldY )\n\t{\n\t\tvar cellX = WorldToCell( worldX );\n\t\tvar cellY = WorldToCell( worldY );\n\t\tvar coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );\n\n\t\tif ( !_chunks.TryGetValue( coord, out var cells ) )\n\t\t\treturn default;\n\n\t\treturn cells[Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution )];\n\t}\n\n\t/// <summary>\n\t/// Reduces density in a radius, removing samples that reach zero.\n\t/// </summary>\n\tpublic void Erase( Vector3 center, float radius, float strength )\n\t{\n\t\tvar radiusSq = radius * radius;\n\t\tvar minCellX = WorldToCell( center.x - radius );\n\t\tvar maxCellX = WorldToCell( center.x + radius );\n\t\tvar minCellY = WorldToCell( center.y - radius );\n\t\tvar maxCellY = WorldToCell( center.y + radius );\n\n\t\tfor ( var cy = minCellY; cy <= maxCellY; cy++ )\n\t\t{\n\t\t\tfor ( var cx = minCellX; cx <= maxCellX; cx++ )\n\t\t\t{\n\t\t\t\tvar coord = new ChunkCoord( FloorDiv( cx, ChunkResolution ), FloorDiv( cy, ChunkResolution ) );\n\t\t\t\tif ( !_chunks.TryGetValue( coord, out var cells ) )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar wx = (cx + 0.5f) * CellSize;\n\t\t\t\tvar wy = (cy + 0.5f) * CellSize;\n\t\t\t\tvar dx = wx - center.x;\n\t\t\t\tvar dy = wy - center.y;\n\t\t\t\tif ( dx * dx + dy * dy > radiusSq )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar index = Mod( cy, ChunkResolution ) * ChunkResolution + Mod( cx, ChunkResolution );\n\t\t\t\tref var cell = ref cells[index];\n\t\t\t\tif ( (cell.Packed & 0xFF) == 0 )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar density = Math.Max( cell.Density - strength, 0.0f );\n\t\t\t\tcell.Packed = density <= 0.0f ? 0u : Cell.Pack( density, cell.Normal );\n\t\t\t\tRevision++;\n\t\t\t}\n\t\t}\n\n\t\tPruneEmptyChunks();\n\t}\n\n\tpublic void ClearAll()\n\t{\n\t\tif ( _chunks.Count == 0 ) return;\n\n\t\t_chunks.Clear();\n\t\tRevision++;\n\t}\n\n\tprivate void PruneEmptyChunks()\n\t{\n\t\tList<ChunkCoord> empty = null;\n\n\t\tforeach ( var (coord, cells) in _chunks )\n\t\t{\n\t\t\tvar used = false;\n\t\t\tfor ( var i = 0; i < cells.Length; i++ )\n\t\t\t{\n\t\t\t\tif ( (cells[i].Packed & 0xFF) != 0 ) { used = true; break; }\n\t\t\t}\n\n\t\t\tif ( !used )\n\t\t\t{\n\t\t\t\tempty ??= [];\n\t\t\t\tempty.Add( coord );\n\t\t\t}\n\t\t}\n\n\t\tif ( empty is null ) return;\n\n\t\tforeach ( var coord in empty )\n\t\t\t_chunks.Remove( coord );\n\t}\n\n\tpublic override void Serialize( ref Writer writer )\n\t{\n\t\twriter.Stream.Write( _chunks.Count );\n\n\t\tforeach ( var (coord, cells) in _chunks )\n\t\t{\n\t\t\twriter.Stream.Write( coord.X );\n\t\t\twriter.Stream.Write( coord.Y );\n\n\t\t\tfor ( var i = 0; i < CellsPerChunk; i++ )\n\t\t\t{\n\t\t\t\twriter.Stream.Write( cells[i].Height );\n\t\t\t\twriter.Stream.Write( cells[i].Packed );\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic override void Deserialize( ref Reader reader )\n\t{\n\t\t_chunks.Clear();\n\n\t\tvar chunkCount = reader.Stream.Read<int>();\n\n\t\tfor ( var c = 0; c < chunkCount; c++ )\n\t\t{\n\t\t\tvar coord = new ChunkCoord( reader.Stream.Read<int>(), reader.Stream.Read<int>() );\n\t\t\tvar cells = new Cell[CellsPerChunk];\n\n\t\t\tfor ( var i = 0; i < CellsPerChunk; i++ )\n\t\t\t{\n\t\t\t\tcells[i].Height = reader.Stream.Read<float>();\n\t\t\t\tcells[i].Packed = reader.Stream.Read<uint>();\n\t\t\t}\n\n\t\t\t_chunks[coord] = cells;\n\t\t}\n\n\t\tRevision++;\n\t}\n}\n"
},
{
"Ident": "redsnail.grasstool",
"Path": "Code/GrassRenderer.cs",
"FileName": "GrassRenderer.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 341332,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Runtime.InteropServices;\nusing Sandbox;\nusing Sandbox.Rendering;\nusing RenderStage = Sandbox.Rendering.Stage;\n\nnamespace RedSnail.GrassTool;\n\n/// <summary>\n/// Renders a painted grass field. Each frame a compute pass expands the density map into blade\n/// instances for whatever is near the camera, then a single indirect draw renders them all.\n/// No blade ever exists on the CPU, and nothing is stored per-blade in the scene.\n/// </summary>\n[Icon( \"grass\" ), Group( \"Grass\" ), Title( \"Grass Renderer\" )]\npublic sealed class GrassRenderer : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer\n{\n\t[StructLayout( LayoutKind.Sequential )]\n\tprivate struct GpuChunk\n\t{\n\t\tpublic Vector2 Origin;\n\t\tpublic int CellOffset;\n\t\tpublic int Pad;\n\t}\n\n\t[StructLayout( LayoutKind.Sequential )]\n\tprivate struct GpuBlade\n\t{\n\t\tpublic Vector3 Position;\n\t\tpublic float Yaw;\n\t\tpublic Vector3 Normal;\n\t\tpublic float Height;\n\t\tpublic float Width;\n\t\tpublic float Tint;\n\t\tpublic float Phase;\n\t\tpublic float Pad; // keeps the struct at 48 bytes\n\t}\n\n\t// Must match GRASS_BLADE_* in grass_shared.fxc.\n\tprivate const int BladeStripVerts = 7;\n\tprivate const int BladeVertexCount = (BladeStripVerts - 2) * 3;\n\n\t// Byte offset of InstanceCount within IndirectDrawArguments. The struct is sequential\n\t// { uint VertexCount; uint InstanceCount; uint FirstVertex; uint FirstInstance; }, so this is\n\t// fixed at 4. Marshal.OffsetOf would express it directly but is outside the sandbox whitelist.\n\tprivate const int ArgsInstanceCountOffset = 4;\n\n\t[Property, Group( \"General\" )]\n\tpublic GrassDefinition Definition { get; set; }\n\n\t/// <summary>\n\t/// Ceiling on blades alive at once. Each costs 48 bytes of GPU memory, so 500k is ~24 MB.\n\t/// Raise it if dense fields visibly clip out at the far edge of the render distance.\n\t/// </summary>\n\t[Property, Group( \"General\" ), Range( 50000, 4000000 )]\n\tpublic int MaxBlades { get; set; } = 500000;\n\n\t/// <summary>\n\t/// How far outside the camera frustum blades are still generated, in world units. Culling is\n\t/// done against the frustum as it was when the frame started, so turning the camera brings in\n\t/// blades that were never generated. Raise this if they visibly stream in at the screen edges,\n\t/// which is most obvious at low or capped framerates.\n\t/// </summary>\n\t[Property, Group( \"General\" ), Range( 0, 4000 )]\n\tpublic float CullPadding { get; set; } = 1000.0f;\n\n\t/// <summary>Painted coverage. Serialized as a binary blob, not JSON.</summary>\n\t[Property, Hide]\n\tpublic GrassStorage Storage { get; set; } = new();\n\n\tprivate ComputeShader _generateShader;\n\tprivate Material _material;\n\tprivate CommandList _commandList;\n\n\tprivate GpuBuffer<GpuChunk> _chunkBuffer;\n\tprivate GpuBuffer<GrassStorage.Cell> _cellBuffer;\n\tprivate GpuBuffer<int> _visibleChunkBuffer;\n\tprivate GpuBuffer<Vector4> _frustumPlaneBuffer;\n\tprivate GpuBuffer<GpuBlade> _bladeBuffer;\n\tprivate GpuBuffer<GpuBuffer.IndirectDrawArguments> _argsBuffer;\n\n\tprivate readonly List<Vector2> _chunkOrigins = [];\n\tprivate int[] _visibleScratch = [];\n\tprivate readonly Vector4[] _planeScratch = new Vector4[6];\n\n\tprivate CameraComponent _lastCamera;\n\tprivate int _chunkCount;\n\tprivate int _uploadedVersion = -1;\n\tprivate int _uploadedMaxBlades = -1;\n\n\tprotected override void OnEnabled()\n\t{\n\t\tStorage ??= new GrassStorage();\n\n\t\t_generateShader = new ComputeShader(\"grass_generate_cs\");\n\t\t_material = Material.FromShader(\"grass\");\n\t\t_commandList = new CommandList(\"Grass Rendering\");\n\n\t\t// Force a full re-upload: the buffers were released on disable, so a matching version\n\t\t// number here would otherwise leave the compute pass reading freed resources.\n\t\t_uploadedVersion = -1;\n\t\t_uploadedMaxBlades = -1;\n\n\t\tRefreshBuffers();\n\t}\n\n\tprotected override void OnDisabled()\n\t{\n\t\t_lastCamera?.RemoveCommandList(_commandList);\n\t\t_lastCamera = null;\n\n\t\t_commandList?.Reset();\n\t\t_commandList = null;\n\n\t\tReleaseBuffers();\n\n\t\t_generateShader = null;\n\t\t_material = null;\n\n\t\t_uploadedVersion = -1;\n\t\t_uploadedMaxBlades = -1;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tvar renderCamera = GetRenderCamera();\n\n\t\t// Re-attach when the camera changes, and also when the one we attached to stopped being\n\t\t// valid. Leaving play mode destroys the play camera without the reference here changing,\n\t\t// so comparing references alone would leave us bound to a dead camera forever.\n\t\tif (renderCamera != _lastCamera || !_lastCamera.IsValid())\n\t\t{\n\t\t\tif (_lastCamera.IsValid())\n\t\t\t\t_lastCamera.RemoveCommandList(_commandList);\n\n\t\t\t_lastCamera = null;\n\n\t\t\tif (renderCamera.IsValid())\n\t\t\t{\n\t\t\t\trenderCamera.AddCommandList(_commandList, RenderStage.AfterOpaque);\n\t\t\t\t_lastCamera = renderCamera;\n\t\t\t}\n\t\t}\n\n\t\t// Nothing to draw through until a camera exists. State is cleared above, so we pick one\n\t\t// up as soon as one appears.\n\t\tif (!_lastCamera.IsValid())\n\t\t\treturn;\n\n\t\t// Culling follows whichever camera the viewport actually looks through, which is not\n\t\t// necessarily the one replaying the list.\n\t\tvar cullCamera = GetCullCamera();\n\t\t\n\t\tif (!cullCamera.IsValid())\n\t\t\treturn;\n\n\t\tRefreshBuffers();\n\t\tRecordCommandList(cullCamera);\n\t}\n\n\t/// <summary>\n\t/// The camera whose command list actually replays. A scene camera does so in the editor\n\t/// viewport as well as in game, so it wins when one exists; with an empty scene the editor\n\t/// camera is the only thing left that will replay ours.\n\t/// </summary>\n\tprivate CameraComponent GetRenderCamera()\n\t{\n\t\tif (Scene.Camera.IsValid())\n\t\t\treturn Scene.Camera;\n\n\t\tif (Scene.IsEditor)\n\t\t\treturn Application.Editor?.Camera;\n\n\t\treturn null;\n\t}\n\n\t/// <summary>\n\t/// The camera the blades are generated for. While editing this is the viewport camera, or\n\t/// nothing outside the game camera's frustum would ever be generated.\n\t/// </summary>\n\tprivate CameraComponent GetCullCamera()\n\t{\n\t\tif (Scene.IsEditor)\n\t\t{\n\t\t\tvar editorCamera = Application.Editor?.Camera;\n\t\t\t\n\t\t\tif (editorCamera.IsValid())\n\t\t\t\treturn editorCamera;\n\t\t}\n\n\t\treturn Scene.Camera;\n\t}\n\n\t/// <summary>\n\t/// Re-packs painted chunks into GPU buffers. Skipped entirely unless the painted data or the\n\t/// blade budget actually changed.\n\t/// </summary>\n\tprivate void RefreshBuffers()\n\t{\n\t\tif ( Storage is null || Storage.ChunkCount == 0 )\n\t\t{\n\t\t\tif ( _chunkCount != 0 )\n\t\t\t{\n\t\t\t\tReleaseBuffers();\n\t\t\t\t_chunkCount = 0;\n\t\t\t}\n\n\t\t\t_uploadedVersion = Storage?.Revision ?? -1;\n\t\t\treturn;\n\t\t}\n\n\t\tif ( _uploadedVersion == Storage.Revision && _uploadedMaxBlades == MaxBlades && _chunkBuffer is not null )\n\t\t\treturn;\n\n\t\tReleaseBuffers();\n\n\t\t_chunkCount = Storage.ChunkCount;\n\t\t_uploadedVersion = Storage.Revision;\n\t\t_uploadedMaxBlades = MaxBlades;\n\n\t\tvar chunks = new GpuChunk[_chunkCount];\n\t\tvar cells = new GrassStorage.Cell[_chunkCount * GrassStorage.CellsPerChunk];\n\n\t\t_chunkOrigins.Clear();\n\n\t\tvar index = 0;\n\t\tforeach ( var (coord, cellData) in Storage.Chunks )\n\t\t{\n\t\t\tvar origin = GrassStorage.ChunkOrigin( coord );\n\t\t\tvar offset = index * GrassStorage.CellsPerChunk;\n\n\t\t\tchunks[index] = new GpuChunk { Origin = origin, CellOffset = offset };\n\t\t\tArray.Copy( cellData, 0, cells, offset, GrassStorage.CellsPerChunk );\n\t\t\t_chunkOrigins.Add( origin );\n\n\t\t\tindex++;\n\t\t}\n\n\t\t_chunkBuffer = new GpuBuffer<GpuChunk>( _chunkCount, GpuBuffer.UsageFlags.Structured );\n\t\t_chunkBuffer.SetData( chunks );\n\n\t\t_cellBuffer = new GpuBuffer<GrassStorage.Cell>( cells.Length, GpuBuffer.UsageFlags.Structured );\n\t\t_cellBuffer.SetData( cells );\n\n\t\t_visibleChunkBuffer = new GpuBuffer<int>( _chunkCount, GpuBuffer.UsageFlags.Structured );\n\t\t_visibleScratch = new int[_chunkCount];\n\n\t\t_frustumPlaneBuffer = new GpuBuffer<Vector4>( 6, GpuBuffer.UsageFlags.Structured );\n\n\t\t_bladeBuffer = new GpuBuffer<GpuBlade>( MaxBlades, GpuBuffer.UsageFlags.Structured | GpuBuffer.UsageFlags.Append );\n\n\t\t_argsBuffer = new GpuBuffer<GpuBuffer.IndirectDrawArguments>( 1, GpuBuffer.UsageFlags.IndirectDrawArguments );\n\t\t_argsBuffer.SetData( new[]\n\t\t{\n\t\t\tnew GpuBuffer.IndirectDrawArguments { VertexCount = BladeVertexCount }\n\t\t} );\n\t}\n\n\tprivate void RecordCommandList( CameraComponent camera )\n\t{\n\t\t_commandList.Reset();\n\n\t\tif ( _chunkCount == 0 || _bladeBuffer is null )\n\t\t\treturn;\n\n\t\tvar definition = Definition;\n\t\tvar cameraPosition = camera.WorldPosition;\n\n\t\tvar visibleCount = CollectVisibleChunks( cameraPosition, definition?.MaxDistance ?? 12000.0f );\n\t\tif ( visibleCount == 0 )\n\t\t\treturn;\n\n\t\tUploadFrustumPlanes( camera );\n\n\t\t_commandList.Attributes.Set( \"GrassChunks\", _chunkBuffer );\n\t\t_commandList.Attributes.Set( \"GrassCells\", _cellBuffer );\n\t\t_commandList.Attributes.Set( \"GrassVisibleChunks\", _visibleChunkBuffer );\n\t\t_commandList.Attributes.Set( \"GrassFrustumPlanes\", _frustumPlaneBuffer );\n\t\t_commandList.Attributes.Set( \"GrassBlades\", _bladeBuffer );\n\t\t_commandList.Attributes.Set( \"GrassVisibleChunkCount\", visibleCount );\n\t\t_commandList.Attributes.Set( \"GrassCameraPos\", cameraPosition );\n\t\t_commandList.Attributes.Set( \"GrassTime\", RealTime.Now );\n\n\t\tdefinition?.ApplyTo( _commandList.Attributes );\n\n\t\t_commandList.ResourceBarrierTransition( _bladeBuffer, ResourceState.UnorderedAccess );\n\t\t_commandList.SetCounterValue( _bladeBuffer, 0 );\n\n\t\t_commandList.DispatchCompute( _generateShader, visibleCount * GrassStorage.CellsPerChunk, 1, 1 );\n\n\t\t// The appends must all land before the counter is read into the draw arguments.\n\t\t_commandList.UavBarrier( _bladeBuffer );\n\n\t\t_commandList.ResourceBarrierTransition( _argsBuffer, ResourceState.CopyDestination );\n\t\t_commandList.CopyStructureCount( _bladeBuffer, _argsBuffer, ArgsInstanceCountOffset );\n\n\t\t_commandList.ResourceBarrierTransition( _bladeBuffer, ResourceState.GenericRead );\n\t\t_commandList.ResourceBarrierTransition( _argsBuffer, ResourceState.IndirectArgument );\n\n\t\t_commandList.DrawInstancedIndirect( _material, _argsBuffer );\n\t}\n\n\t/// <summary>\n\t/// Narrows the dispatch to chunks near the camera. Per-blade frustum culling happens on the\n\t/// GPU; this only has to be conservative.\n\t/// </summary>\n\tprivate int CollectVisibleChunks( Vector3 cameraPosition, float maxDistance )\n\t{\n\t\t// A chunk's near corner can be in range while its centre isn't, hence the circumradius.\n\t\tvar cullRadius = maxDistance + GrassStorage.ChunkSize * 0.7072f;\n\t\tvar cullRadiusSq = cullRadius * cullRadius;\n\n\t\tvar halfChunk = GrassStorage.ChunkSize * 0.5f;\n\t\tvar count = 0;\n\n\t\tfor ( var i = 0; i < _chunkOrigins.Count; i++ )\n\t\t{\n\t\t\tvar origin = _chunkOrigins[i];\n\t\t\tvar dx = origin.x + halfChunk - cameraPosition.x;\n\t\t\tvar dy = origin.y + halfChunk - cameraPosition.y;\n\n\t\t\tif ( dx * dx + dy * dy > cullRadiusSq )\n\t\t\t\tcontinue;\n\n\t\t\t_visibleScratch[count++] = i;\n\t\t}\n\n\t\tif ( count > 0 )\n\t\t\t_visibleChunkBuffer.SetData( _visibleScratch.AsSpan( 0, count ) );\n\n\t\treturn count;\n\t}\n\n\tprivate void UploadFrustumPlanes( CameraComponent camera )\n\t{\n\t\tvar frustum = camera.GetFrustum();\n\n\t\t// These planes are sampled once on the CPU but the blades they cull are not drawn until the\n\t\t// frame presents, by which point the camera has kept turning. Pushing every plane outward\n\t\t// gives the generation something to work with at the screen edges - without it, blades\n\t\t// rotating into view were culled before they were ever needed, which reads as them\n\t\t// streaming in from the sides. The slack that buys scales with the frame time, so a capped\n\t\t// or struggling framerate is exactly when it matters most.\n\t\tvar padding = MathF.Max( CullPadding, 0.0f );\n\n\t\t// Plane.GetDistance is dot( point, Normal ) - Distance, so w is negated to let the shader\n\t\t// use a plain dot( xyz, p ) + w. Adding the padding there slides the plane outward.\n\t\t_planeScratch[0] = ToVector4( frustum.LeftPlane, padding );\n\t\t_planeScratch[1] = ToVector4( frustum.RightPlane, padding );\n\t\t_planeScratch[2] = ToVector4( frustum.TopPlane, padding );\n\t\t_planeScratch[3] = ToVector4( frustum.BottomPlane, padding );\n\t\t_planeScratch[4] = ToVector4( frustum.NearPlane, padding );\n\n\t\t// The far plane is left tight - MaxDistance already governs the far edge, and padding it\n\t\t// would only generate blades that fade out before they are ever visible.\n\t\t_planeScratch[5] = ToVector4( frustum.FarPlane, 0.0f );\n\n\t\t_frustumPlaneBuffer.SetData( _planeScratch );\n\n\t\tstatic Vector4 ToVector4( Plane plane, float padding ) =>\n\t\t\tnew( plane.Normal.x, plane.Normal.y, plane.Normal.z, -plane.Distance + padding );\n\t}\n\n\tprivate void ReleaseBuffers()\n\t{\n\t\t_chunkBuffer?.Dispose();\n\t\t_chunkBuffer = null;\n\n\t\t_cellBuffer?.Dispose();\n\t\t_cellBuffer = null;\n\n\t\t_visibleChunkBuffer?.Dispose();\n\t\t_visibleChunkBuffer = null;\n\n\t\t_frustumPlaneBuffer?.Dispose();\n\t\t_frustumPlaneBuffer = null;\n\n\t\t_bladeBuffer?.Dispose();\n\t\t_bladeBuffer = null;\n\n\t\t_argsBuffer?.Dispose();\n\t\t_argsBuffer = null;\n\n\t\t_chunkOrigins.Clear();\n\t\t_chunkCount = 0;\n\t}\n\n\t/// <summary>\n\t/// Called by the editor tool after painting, so the next frame re-uploads the density map.\n\t/// </summary>\n\tpublic void MarkDirty() => _uploadedVersion = -1;\n\n\tprotected override void DrawGizmos()\n\t{\n\t\tif ( !Gizmo.IsSelected || Storage is null || Storage.ChunkCount == 0 )\n\t\t\treturn;\n\n\t\tGizmo.Draw.Color = Color.Green.WithAlpha( 0.35f );\n\n\t\tforeach ( var (coord, _) in Storage.Chunks )\n\t\t{\n\t\t\tvar origin = GrassStorage.ChunkOrigin( coord );\n\t\t\tvar mins = new Vector3( origin.x, origin.y, 0 );\n\t\t\tvar maxs = new Vector3( origin.x + GrassStorage.ChunkSize, origin.y + GrassStorage.ChunkSize, 0 );\n\n\t\t\tGizmo.Draw.LineBBox( new BBox( WorldTransform.PointToLocal( mins ), WorldTransform.PointToLocal( maxs ) ) );\n\t\t}\n\t}\n}\n"
},
{
"Ident": "redsnail.grasstool",
"Path": "Code/GrassStorage.cs",
"FileName": "GrassStorage.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 341332,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Runtime.InteropServices;\nusing Sandbox;\n\nnamespace RedSnail.GrassTool;\n\n/// <summary>\n/// Sparse painted grass coverage, stored as a chunked grid of density samples rather than\n/// individual blade transforms. Blades are generated procedurally on the GPU from this data,\n/// so a square kilometre of dense grass costs a few megabytes instead of hundreds.\n/// </summary>\npublic sealed class GrassStorage : BlobData\n{\n\tpublic override int Version => 1;\n\n\t/// <summary>Cells along one edge of a chunk.</summary>\n\tpublic const int ChunkResolution = 64;\n\n\t/// <summary>World-space size of a single density cell, in source units.</summary>\n\tpublic const float CellSize = 32.0f;\n\n\t/// <summary>World-space size of a chunk edge, in source units.</summary>\n\tpublic const float ChunkSize = ChunkResolution * CellSize;\n\n\tpublic const int CellsPerChunk = ChunkResolution * ChunkResolution;\n\n\t/// <summary>\n\t/// One density sample. Height and normal are baked when painting so grass sits on any\n\t/// geometry, not just terrain. Matches the HLSL <c>GrassCell</c> struct exactly.\n\t/// </summary>\n\t[StructLayout( LayoutKind.Sequential )]\n\tpublic struct Cell\n\t{\n\t\tpublic float Height;\n\n\t\t/// <summary>density (0-7) | normal.x (8-15) | normal.y (16-23) | unused (24-31)</summary>\n\t\tpublic uint Packed;\n\n\t\tpublic readonly float Density => (Packed & 0xFF) / 255.0f;\n\n\t\tpublic readonly Vector3 Normal\n\t\t{\n\t\t\tget\n\t\t\t{\n\t\t\t\tvar x = ((Packed >> 8) & 0xFF) / 127.5f - 1.0f;\n\t\t\t\tvar y = ((Packed >> 16) & 0xFF) / 127.5f - 1.0f;\n\t\t\t\tvar z = MathF.Sqrt( Math.Clamp( 1.0f - x * x - y * y, 0.0f, 1.0f ) );\n\t\t\t\treturn new Vector3( x, y, z );\n\t\t\t}\n\t\t}\n\n\t\tpublic static uint Pack( float density, Vector3 normal )\n\t\t{\n\t\t\tvar d = (uint)Math.Clamp( density * 255.0f + 0.5f, 0.0f, 255.0f );\n\t\t\tvar nx = (uint)Math.Clamp( (normal.x + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );\n\t\t\tvar ny = (uint)Math.Clamp( (normal.y + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );\n\t\t\treturn d | (nx << 8) | (ny << 16);\n\t\t}\n\t}\n\n\tpublic readonly record struct ChunkCoord( int X, int Y );\n\n\tprivate readonly Dictionary<ChunkCoord, Cell[]> _chunks = [];\n\n\t/// <summary>Bumped on every mutation so the renderer knows to re-upload its GPU buffers.</summary>\n\tpublic int Revision { get; private set; }\n\n\tpublic int ChunkCount => _chunks.Count;\n\n\tpublic IReadOnlyDictionary<ChunkCoord, Cell[]> Chunks => _chunks;\n\n\tpublic static ChunkCoord WorldToChunk( Vector3 world ) => new(\n\t\t(int)MathF.Floor( world.x / ChunkSize ),\n\t\t(int)MathF.Floor( world.y / ChunkSize ) );\n\n\tpublic static Vector2 ChunkOrigin( ChunkCoord coord ) => new( coord.X * ChunkSize, coord.Y * ChunkSize );\n\n\t/// <summary>Global cell index on an axis. Negative world positions floor correctly.</summary>\n\tprivate static int WorldToCell( float world ) => (int)MathF.Floor( world / CellSize );\n\n\tprivate static int FloorDiv( int a, int b ) => a >= 0 ? a / b : ~(~a / b);\n\n\tprivate static int Mod( int a, int b )\n\t{\n\t\tvar r = a % b;\n\t\treturn r < 0 ? r + b : r;\n\t}\n\n\t/// <summary>\n\t/// Writes a density sample at a world position, baking the surface height and normal alongside it.\n\t/// Density of zero frees the sample.\n\t/// </summary>\n\tpublic void SetCell( float worldX, float worldY, float density, float height, Vector3 normal )\n\t{\n\t\tvar cellX = WorldToCell( worldX );\n\t\tvar cellY = WorldToCell( worldY );\n\t\tvar coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );\n\n\t\tif ( !_chunks.TryGetValue( coord, out var cells ) )\n\t\t{\n\t\t\tif ( density <= 0.0f ) return;\n\n\t\t\tcells = new Cell[CellsPerChunk];\n\t\t\t_chunks[coord] = cells;\n\t\t}\n\n\t\tvar index = Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution );\n\t\tcells[index] = new Cell { Height = height, Packed = Cell.Pack( density, normal ) };\n\t\tRevision++;\n\t}\n\n\tpublic Cell GetCell( float worldX, float worldY )\n\t{\n\t\tvar cellX = WorldToCell( worldX );\n\t\tvar cellY = WorldToCell( worldY );\n\t\tvar coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );\n\n\t\tif ( !_chunks.TryGetValue( coord, out var cells ) )\n\t\t\treturn default;\n\n\t\treturn cells[Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution )];\n\t}\n\n\t/// <summary>\n\t/// Reduces density in a radius, removing samples that reach zero.\n\t/// </summary>\n\tpublic void Erase( Vector3 center, float radius, float strength )\n\t{\n\t\tvar radiusSq = radius * radius;\n\t\tvar minCellX = WorldToCell( center.x - radius );\n\t\tvar maxCellX = WorldToCell( center.x + radius );\n\t\tvar minCellY = WorldToCell( center.y - radius );\n\t\tvar maxCellY = WorldToCell( center.y + radius );\n\n\t\tfor ( var cy = minCellY; cy <= maxCellY; cy++ )\n\t\t{\n\t\t\tfor ( var cx = minCellX; cx <= maxCellX; cx++ )\n\t\t\t{\n\t\t\t\tvar coord = new ChunkCoord( FloorDiv( cx, ChunkResolution ), FloorDiv( cy, ChunkResolution ) );\n\t\t\t\tif ( !_chunks.TryGetValue( coord, out var cells ) )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar wx = (cx + 0.5f) * CellSize;\n\t\t\t\tvar wy = (cy + 0.5f) * CellSize;\n\t\t\t\tvar dx = wx - center.x;\n\t\t\t\tvar dy = wy - center.y;\n\t\t\t\tif ( dx * dx + dy * dy > radiusSq )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar index = Mod( cy, ChunkResolution ) * ChunkResolution + Mod( cx, ChunkResolution );\n\t\t\t\tref var cell = ref cells[index];\n\t\t\t\tif ( (cell.Packed & 0xFF) == 0 )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar density = Math.Max( cell.Density - strength, 0.0f );\n\t\t\t\tcell.Packed = density <= 0.0f ? 0u : Cell.Pack( density, cell.Normal );\n\t\t\t\tRevision++;\n\t\t\t}\n\t\t}\n\n\t\tPruneEmptyChunks();\n\t}\n\n\tpublic void ClearAll()\n\t{\n\t\tif ( _chunks.Count == 0 ) return;\n\n\t\t_chunks.Clear();\n\t\tRevision++;\n\t}\n\n\tprivate void PruneEmptyChunks()\n\t{\n\t\tList<ChunkCoord> empty = null;\n\n\t\tforeach ( var (coord, cells) in _chunks )\n\t\t{\n\t\t\tvar used = false;\n\t\t\tfor ( var i = 0; i < cells.Length; i++ )\n\t\t\t{\n\t\t\t\tif ( (cells[i].Packed & 0xFF) != 0 ) { used = true; break; }\n\t\t\t}\n\n\t\t\tif ( !used )\n\t\t\t{\n\t\t\t\tempty ??= [];\n\t\t\t\tempty.Add( coord );\n\t\t\t}\n\t\t}\n\n\t\tif ( empty is null ) return;\n\n\t\tforeach ( var coord in empty )\n\t\t\t_chunks.Remove( coord );\n\t}\n\n\tpublic override void Serialize( ref Writer writer )\n\t{\n\t\twriter.Stream.Write( _chunks.Count );\n\n\t\tforeach ( var (coord, cells) in _chunks )\n\t\t{\n\t\t\twriter.Stream.Write( coord.X );\n\t\t\twriter.Stream.Write( coord.Y );\n\n\t\t\tfor ( var i = 0; i < CellsPerChunk; i++ )\n\t\t\t{\n\t\t\t\twriter.Stream.Write( cells[i].Height );\n\t\t\t\twriter.Stream.Write( cells[i].Packed );\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic override void Deserialize( ref Reader reader )\n\t{\n\t\t_chunks.Clear();\n\n\t\tvar chunkCount = reader.Stream.Read<int>();\n\n\t\tfor ( var c = 0; c < chunkCount; c++ )\n\t\t{\n\t\t\tvar coord = new ChunkCoord( reader.Stream.Read<int>(), reader.Stream.Read<int>() );\n\t\t\tvar cells = new Cell[CellsPerChunk];\n\n\t\t\tfor ( var i = 0; i < CellsPerChunk; i++ )\n\t\t\t{\n\t\t\t\tcells[i].Height = reader.Stream.Read<float>();\n\t\t\t\tcells[i].Packed = reader.Stream.Read<uint>();\n\t\t\t}\n\n\t\t\t_chunks[coord] = cells;\n\t\t}\n\n\t\tRevision++;\n\t}\n}\n"
},
{
"Ident": "redsnail.grasstool",
"Path": "GrassRenderer.cs",
"FileName": "GrassRenderer.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 341332,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Runtime.InteropServices;\nusing Sandbox;\nusing Sandbox.Rendering;\nusing RenderStage = Sandbox.Rendering.Stage;\n\nnamespace RedSnail.GrassTool;\n\n/// <summary>\n/// Renders a painted grass field. Each frame a compute pass expands the density map into blade\n/// instances for whatever is near the camera, then a single indirect draw renders them all.\n/// No blade ever exists on the CPU, and nothing is stored per-blade in the scene.\n/// </summary>\n[Icon( \"grass\" ), Group( \"Grass\" ), Title( \"Grass Renderer\" )]\npublic sealed class GrassRenderer : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer\n{\n\t[StructLayout( LayoutKind.Sequential )]\n\tprivate struct GpuChunk\n\t{\n\t\tpublic Vector2 Origin;\n\t\tpublic int CellOffset;\n\t\tpublic int Pad;\n\t}\n\n\t[StructLayout( LayoutKind.Sequential )]\n\tprivate struct GpuBlade\n\t{\n\t\tpublic Vector3 Position;\n\t\tpublic float Yaw;\n\t\tpublic Vector3 Normal;\n\t\tpublic float Height;\n\t\tpublic float Width;\n\t\tpublic float Tint;\n\t\tpublic float Phase;\n\t\tpublic float Pad; // keeps the struct at 48 bytes\n\t}\n\n\t// Must match GRASS_BLADE_* in grass_shared.fxc.\n\tprivate const int BladeStripVerts = 7;\n\tprivate const int BladeVertexCount = (BladeStripVerts - 2) * 3;\n\n\t// Byte offset of InstanceCount within IndirectDrawArguments. The struct is sequential\n\t// { uint VertexCount; uint InstanceCount; uint FirstVertex; uint FirstInstance; }, so this is\n\t// fixed at 4. Marshal.OffsetOf would express it directly but is outside the sandbox whitelist.\n\tprivate const int ArgsInstanceCountOffset = 4;\n\n\t[Property, Group( \"General\" )]\n\tpublic GrassDefinition Definition { get; set; }\n\n\t/// <summary>\n\t/// Ceiling on blades alive at once. Each costs 48 bytes of GPU memory, so 500k is ~24 MB.\n\t/// Raise it if dense fields visibly clip out at the far edge of the render distance.\n\t/// </summary>\n\t[Property, Group( \"General\" ), Range( 50000, 4000000 )]\n\tpublic int MaxBlades { get; set; } = 500000;\n\n\t/// <summary>\n\t/// How far outside the camera frustum blades are still generated, in world units. Culling is\n\t/// done against the frustum as it was when the frame started, so turning the camera brings in\n\t/// blades that were never generated. Raise this if they visibly stream in at the screen edges,\n\t/// which is most obvious at low or capped framerates.\n\t/// </summary>\n\t[Property, Group( \"General\" ), Range( 0, 4000 )]\n\tpublic float CullPadding { get; set; } = 1000.0f;\n\n\t/// <summary>Painted coverage. Serialized as a binary blob, not JSON.</summary>\n\t[Property, Hide]\n\tpublic GrassStorage Storage { get; set; } = new();\n\n\tprivate ComputeShader _generateShader;\n\tprivate Material _material;\n\tprivate CommandList _commandList;\n\n\tprivate GpuBuffer<GpuChunk> _chunkBuffer;\n\tprivate GpuBuffer<GrassStorage.Cell> _cellBuffer;\n\tprivate GpuBuffer<int> _visibleChunkBuffer;\n\tprivate GpuBuffer<Vector4> _frustumPlaneBuffer;\n\tprivate GpuBuffer<GpuBlade> _bladeBuffer;\n\tprivate GpuBuffer<GpuBuffer.IndirectDrawArguments> _argsBuffer;\n\n\tprivate readonly List<Vector2> _chunkOrigins = [];\n\tprivate int[] _visibleScratch = [];\n\tprivate readonly Vector4[] _planeScratch = new Vector4[6];\n\n\tprivate CameraComponent _lastCamera;\n\tprivate int _chunkCount;\n\tprivate int _uploadedVersion = -1;\n\tprivate int _uploadedMaxBlades = -1;\n\n\tprotected override void OnEnabled()\n\t{\n\t\tStorage ??= new GrassStorage();\n\n\t\t_generateShader = new ComputeShader(\"grass_generate_cs\");\n\t\t_material = Material.FromShader(\"grass\");\n\t\t_commandList = new CommandList(\"Grass Rendering\");\n\n\t\t// Force a full re-upload: the buffers were released on disable, so a matching version\n\t\t// number here would otherwise leave the compute pass reading freed resources.\n\t\t_uploadedVersion = -1;\n\t\t_uploadedMaxBlades = -1;\n\n\t\tRefreshBuffers();\n\t}\n\n\tprotected override void OnDisabled()\n\t{\n\t\t_lastCamera?.RemoveCommandList(_commandList);\n\t\t_lastCamera = null;\n\n\t\t_commandList?.Reset();\n\t\t_commandList = null;\n\n\t\tReleaseBuffers();\n\n\t\t_generateShader = null;\n\t\t_material = null;\n\n\t\t_uploadedVersion = -1;\n\t\t_uploadedMaxBlades = -1;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tvar renderCamera = GetRenderCamera();\n\n\t\t// Re-attach when the camera changes, and also when the one we attached to stopped being\n\t\t// valid. Leaving play mode destroys the play camera without the reference here changing,\n\t\t// so comparing references alone would leave us bound to a dead camera forever.\n\t\tif (renderCamera != _lastCamera || !_lastCamera.IsValid())\n\t\t{\n\t\t\tif (_lastCamera.IsValid())\n\t\t\t\t_lastCamera.RemoveCommandList(_commandList);\n\n\t\t\t_lastCamera = null;\n\n\t\t\tif (renderCamera.IsValid())\n\t\t\t{\n\t\t\t\trenderCamera.AddCommandList(_commandList, RenderStage.AfterOpaque);\n\t\t\t\t_lastCamera = renderCamera;\n\t\t\t}\n\t\t}\n\n\t\t// Nothing to draw through until a camera exists. State is cleared above, so we pick one\n\t\t// up as soon as one appears.\n\t\tif (!_lastCamera.IsValid())\n\t\t\treturn;\n\n\t\t// Culling follows whichever camera the viewport actually looks through, which is not\n\t\t// necessarily the one replaying the list.\n\t\tvar cullCamera = GetCullCamera();\n\t\t\n\t\tif (!cullCamera.IsValid())\n\t\t\treturn;\n\n\t\tRefreshBuffers();\n\t\tRecordCommandList(cullCamera);\n\t}\n\n\t/// <summary>\n\t/// The camera whose command list actually replays. A scene camera does so in the editor\n\t/// viewport as well as in game, so it wins when one exists; with an empty scene the editor\n\t/// camera is the only thing left that will replay ours.\n\t/// </summary>\n\tprivate CameraComponent GetRenderCamera()\n\t{\n\t\tif (Scene.Camera.IsValid())\n\t\t\treturn Scene.Camera;\n\n\t\tif (Scene.IsEditor)\n\t\t\treturn Application.Editor?.Camera;\n\n\t\treturn null;\n\t}\n\n\t/// <summary>\n\t/// The camera the blades are generated for. While editing this is the viewport camera, or\n\t/// nothing outside the game camera's frustum would ever be generated.\n\t/// </summary>\n\tprivate CameraComponent GetCullCamera()\n\t{\n\t\tif (Scene.IsEditor)\n\t\t{\n\t\t\tvar editorCamera = Application.Editor?.Camera;\n\t\t\t\n\t\t\tif (editorCamera.IsValid())\n\t\t\t\treturn editorCamera;\n\t\t}\n\n\t\treturn Scene.Camera;\n\t}\n\n\t/// <summary>\n\t/// Re-packs painted chunks into GPU buffers. Skipped entirely unless the painted data or the\n\t/// blade budget actually changed.\n\t/// </summary>\n\tprivate void RefreshBuffers()\n\t{\n\t\tif ( Storage is null || Storage.ChunkCount == 0 )\n\t\t{\n\t\t\tif ( _chunkCount != 0 )\n\t\t\t{\n\t\t\t\tReleaseBuffers();\n\t\t\t\t_chunkCount = 0;\n\t\t\t}\n\n\t\t\t_uploadedVersion = Storage?.Revision ?? -1;\n\t\t\treturn;\n\t\t}\n\n\t\tif ( _uploadedVersion == Storage.Revision && _uploadedMaxBlades == MaxBlades && _chunkBuffer is not null )\n\t\t\treturn;\n\n\t\tReleaseBuffers();\n\n\t\t_chunkCount = Storage.ChunkCount;\n\t\t_uploadedVersion = Storage.Revision;\n\t\t_uploadedMaxBlades = MaxBlades;\n\n\t\tvar chunks = new GpuChunk[_chunkCount];\n\t\tvar cells = new GrassStorage.Cell[_chunkCount * GrassStorage.CellsPerChunk];\n\n\t\t_chunkOrigins.Clear();\n\n\t\tvar index = 0;\n\t\tforeach ( var (coord, cellData) in Storage.Chunks )\n\t\t{\n\t\t\tvar origin = GrassStorage.ChunkOrigin( coord );\n\t\t\tvar offset = index * GrassStorage.CellsPerChunk;\n\n\t\t\tchunks[index] = new GpuChunk { Origin = origin, CellOffset = offset };\n\t\t\tArray.Copy( cellData, 0, cells, offset, GrassStorage.CellsPerChunk );\n\t\t\t_chunkOrigins.Add( origin );\n\n\t\t\tindex++;\n\t\t}\n\n\t\t_chunkBuffer = new GpuBuffer<GpuChunk>( _chunkCount, GpuBuffer.UsageFlags.Structured );\n\t\t_chunkBuffer.SetData( chunks );\n\n\t\t_cellBuffer = new GpuBuffer<GrassStorage.Cell>( cells.Length, GpuBuffer.UsageFlags.Structured );\n\t\t_cellBuffer.SetData( cells );\n\n\t\t_visibleChunkBuffer = new GpuBuffer<int>( _chunkCount, GpuBuffer.UsageFlags.Structured );\n\t\t_visibleScratch = new int[_chunkCount];\n\n\t\t_frustumPlaneBuffer = new GpuBuffer<Vector4>( 6, GpuBuffer.UsageFlags.Structured );\n\n\t\t_bladeBuffer = new GpuBuffer<GpuBlade>( MaxBlades, GpuBuffer.UsageFlags.Structured | GpuBuffer.UsageFlags.Append );\n\n\t\t_argsBuffer = new GpuBuffer<GpuBuffer.IndirectDrawArguments>( 1, GpuBuffer.UsageFlags.IndirectDrawArguments );\n\t\t_argsBuffer.SetData( new[]\n\t\t{\n\t\t\tnew GpuBuffer.IndirectDrawArguments { VertexCount = BladeVertexCount }\n\t\t} );\n\t}\n\n\tprivate void RecordCommandList( CameraComponent camera )\n\t{\n\t\t_commandList.Reset();\n\n\t\tif ( _chunkCount == 0 || _bladeBuffer is null )\n\t\t\treturn;\n\n\t\tvar definition = Definition;\n\t\tvar cameraPosition = camera.WorldPosition;\n\n\t\tvar visibleCount = CollectVisibleChunks( cameraPosition, definition?.MaxDistance ?? 12000.0f );\n\t\tif ( visibleCount == 0 )\n\t\t\treturn;\n\n\t\tUploadFrustumPlanes( camera );\n\n\t\t_commandList.Attributes.Set( \"GrassChunks\", _chunkBuffer );\n\t\t_commandList.Attributes.Set( \"GrassCells\", _cellBuffer );\n\t\t_commandList.Attributes.Set( \"GrassVisibleChunks\", _visibleChunkBuffer );\n\t\t_commandList.Attributes.Set( \"GrassFrustumPlanes\", _frustumPlaneBuffer );\n\t\t_commandList.Attributes.Set( \"GrassBlades\", _bladeBuffer );\n\t\t_commandList.Attributes.Set( \"GrassVisibleChunkCount\", visibleCount );\n\t\t_commandList.Attributes.Set( \"GrassCameraPos\", cameraPosition );\n\t\t_commandList.Attributes.Set( \"GrassTime\", RealTime.Now );\n\n\t\tdefinition?.ApplyTo( _commandList.Attributes );\n\n\t\t_commandList.ResourceBarrierTransition( _bladeBuffer, ResourceState.UnorderedAccess );\n\t\t_commandList.SetCounterValue( _bladeBuffer, 0 );\n\n\t\t_commandList.DispatchCompute( _generateShader, visibleCount * GrassStorage.CellsPerChunk, 1, 1 );\n\n\t\t// The appends must all land before the counter is read into the draw arguments.\n\t\t_commandList.UavBarrier( _bladeBuffer );\n\n\t\t_commandList.ResourceBarrierTransition( _argsBuffer, ResourceState.CopyDestination );\n\t\t_commandList.CopyStructureCount( _bladeBuffer, _argsBuffer, ArgsInstanceCountOffset );\n\n\t\t_commandList.ResourceBarrierTransition( _bladeBuffer, ResourceState.GenericRead );\n\t\t_commandList.ResourceBarrierTransition( _argsBuffer, ResourceState.IndirectArgument );\n\n\t\t_commandList.DrawInstancedIndirect( _material, _argsBuffer );\n\t}\n\n\t/// <summary>\n\t/// Narrows the dispatch to chunks near the camera. Per-blade frustum culling happens on the\n\t/// GPU; this only has to be conservative.\n\t/// </summary>\n\tprivate int CollectVisibleChunks( Vector3 cameraPosition, float maxDistance )\n\t{\n\t\t// A chunk's near corner can be in range while its centre isn't, hence the circumradius.\n\t\tvar cullRadius = maxDistance + GrassStorage.ChunkSize * 0.7072f;\n\t\tvar cullRadiusSq = cullRadius * cullRadius;\n\n\t\tvar halfChunk = GrassStorage.ChunkSize * 0.5f;\n\t\tvar count = 0;\n\n\t\tfor ( var i = 0; i < _chunkOrigins.Count; i++ )\n\t\t{\n\t\t\tvar origin = _chunkOrigins[i];\n\t\t\tvar dx = origin.x + halfChunk - cameraPosition.x;\n\t\t\tvar dy = origin.y + halfChunk - cameraPosition.y;\n\n\t\t\tif ( dx * dx + dy * dy > cullRadiusSq )\n\t\t\t\tcontinue;\n\n\t\t\t_visibleScratch[count++] = i;\n\t\t}\n\n\t\tif ( count > 0 )\n\t\t\t_visibleChunkBuffer.SetData( _visibleScratch.AsSpan( 0, count ) );\n\n\t\treturn count;\n\t}\n\n\tprivate void UploadFrustumPlanes( CameraComponent camera )\n\t{\n\t\tvar frustum = camera.GetFrustum();\n\n\t\t// These planes are sampled once on the CPU but the blades they cull are not drawn until the\n\t\t// frame presents, by which point the camera has kept turning. Pushing every plane outward\n\t\t// gives the generation something to work with at the screen edges - without it, blades\n\t\t// rotating into view were culled before they were ever needed, which reads as them\n\t\t// streaming in from the sides. The slack that buys scales with the frame time, so a capped\n\t\t// or struggling framerate is exactly when it matters most.\n\t\tvar padding = MathF.Max( CullPadding, 0.0f );\n\n\t\t// Plane.GetDistance is dot( point, Normal ) - Distance, so w is negated to let the shader\n\t\t// use a plain dot( xyz, p ) + w. Adding the padding there slides the plane outward.\n\t\t_planeScratch[0] = ToVector4( frustum.LeftPlane, padding );\n\t\t_planeScratch[1] = ToVector4( frustum.RightPlane, padding );\n\t\t_planeScratch[2] = ToVector4( frustum.TopPlane, padding );\n\t\t_planeScratch[3] = ToVector4( frustum.BottomPlane, padding );\n\t\t_planeScratch[4] = ToVector4( frustum.NearPlane, padding );\n\n\t\t// The far plane is left tight - MaxDistance already governs the far edge, and padding it\n\t\t// would only generate blades that fade out before they are ever visible.\n\t\t_planeScratch[5] = ToVector4( frustum.FarPlane, 0.0f );\n\n\t\t_frustumPlaneBuffer.SetData( _planeScratch );\n\n\t\tstatic Vector4 ToVector4( Plane plane, float padding ) =>\n\t\t\tnew( plane.Normal.x, plane.Normal.y, plane.Normal.z, -plane.Distance + padding );\n\t}\n\n\tprivate void ReleaseBuffers()\n\t{\n\t\t_chunkBuffer?.Dispose();\n\t\t_chunkBuffer = null;\n\n\t\t_cellBuffer?.Dispose();\n\t\t_cellBuffer = null;\n\n\t\t_visibleChunkBuffer?.Dispose();\n\t\t_visibleChunkBuffer = null;\n\n\t\t_frustumPlaneBuffer?.Dispose();\n\t\t_frustumPlaneBuffer = null;\n\n\t\t_bladeBuffer?.Dispose();\n\t\t_bladeBuffer = null;\n\n\t\t_argsBuffer?.Dispose();\n\t\t_argsBuffer = null;\n\n\t\t_chunkOrigins.Clear();\n\t\t_chunkCount = 0;\n\t}\n\n\t/// <summary>\n\t/// Called by the editor tool after painting, so the next frame re-uploads the density map.\n\t/// </summary>\n\tpublic void MarkDirty() => _uploadedVersion = -1;\n\n\tprotected override void DrawGizmos()\n\t{\n\t\tif ( !Gizmo.IsSelected || Storage is null || Storage.ChunkCount == 0 )\n\t\t\treturn;\n\n\t\tGizmo.Draw.Color = Color.Green.WithAlpha( 0.35f );\n\n\t\tforeach ( var (coord, _) in Storage.Chunks )\n\t\t{\n\t\t\tvar origin = GrassStorage.ChunkOrigin( coord );\n\t\t\tvar mins = new Vector3( origin.x, origin.y, 0 );\n\t\t\tvar maxs = new Vector3( origin.x + GrassStorage.ChunkSize, origin.y + GrassStorage.ChunkSize, 0 );\n\n\t\t\tGizmo.Draw.LineBBox( new BBox( WorldTransform.PointToLocal( mins ), WorldTransform.PointToLocal( maxs ) ) );\n\t\t}\n\t}\n}\n"
},
{
"Ident": "redsnail.grasstool",
"Path": ".obj/__compiler_extra.cs",
"FileName": "__compiler_extra.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 341332,
"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\", \"Grass Tool\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"grasstool\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"redsnail\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"redsnail.grasstool\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"28\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-08-12T23:58:53.3546626Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.137.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.137.0\")]"
},
{
"Ident": "redsnail.grasstool",
"Path": "Editor/GrassTool.cs",
"FileName": "GrassTool.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 341332,
"Code": "using System;\nusing System.Linq;\nusing Editor;\nusing Editor.TerrainEditor;\nusing Sandbox;\n\nnamespace RedSnail.GrassTool.Editor;\n\n/// <summary>\n/// Paints grass coverage onto any surface. The brush writes density into the target\n/// <see cref=\"GrassRenderer\"/>'s density map, baking the surface height and normal under each\n/// cell so blades sit on whatever geometry is there. Hold Ctrl to erase.\n/// </summary>\n[EditorTool( \"grass\" )]\n[Title( \"Grass\" )]\n[Icon( \"grass\" )]\npublic sealed class GrassPaintTool : EditorTool\n{\n\tpublic BrushSettings BrushSettings { get; private set; } = new();\n\n\tprivate GrassRenderer _target;\n\tprivate bool _erasing;\n\tprivate bool _dragging;\n\tprivate bool _painted;\n\tprivate Vector3 _lastPaintPosition;\n\n\t// Repainting the same spot every frame just burns traces, so the brush has to travel a\n\t// fraction of its own radius before it deposits again.\n\tprivate float PaintStepDistance => BrushSettings.Size * 0.25f;\n\n\tpublic GrassPaintTool()\n\t{\n\t\tRebuildSidebarOnSelectionChange = false;\n\t}\n\n\tpublic override Widget CreateToolSidebar()\n\t{\n\t\tvar sidebar = new ToolSidebarWidget();\n\t\tsidebar.AddTitle( \"Grass Brush\", \"brush\" );\n\t\tsidebar.MinimumWidth = 300;\n\n\t\t{\n\t\t\tvar group = sidebar.AddGroup( \"Brush\" );\n\t\t\tvar so = BrushSettings.GetSerialized();\n\t\t\tgroup.Add( ControlSheet.CreateRow( so.GetProperty( nameof( BrushSettings.Size ) ) ) );\n\t\t\tgroup.Add( ControlSheet.CreateRow( so.GetProperty( nameof( BrushSettings.Opacity ) ) ) );\n\t\t}\n\n\t\t{\n\t\t\tvar group = sidebar.AddGroup( \"Actions\" );\n\n\t\t\tvar clear = new Button( \"Clear All Grass\", \"delete_sweep\" );\n\t\t\tclear.ToolTip = \"Remove every painted cell from the target Grass Renderer\";\n\t\t\tclear.Clicked += () =>\n\t\t\t{\n\t\t\t\tvar target = ResolveTarget();\n\t\t\t\tif ( !target.IsValid() || target.Storage is null )\n\t\t\t\t\treturn;\n\n\t\t\t\t// Wiping the density map throws away every stroke and there is no undo for it,\n\t\t\t\t// so this one gets a confirmation.\n\t\t\t\tDialog.AskConfirm(\n\t\t\t\t\t() =>\n\t\t\t\t\t{\n\t\t\t\t\t\ttarget.Storage.ClearAll();\n\t\t\t\t\t\ttarget.MarkDirty();\n\t\t\t\t\t},\n\t\t\t\t\t\"Are you sure you want to delete all grass? This action cannot be undone.\",\n\t\t\t\t\t\"Delete All Grass\",\n\t\t\t\t\t\"Delete\",\n\t\t\t\t\t\"Cancel\" );\n\t\t\t};\n\t\t\tgroup.Add( clear );\n\t\t}\n\n\t\t// Soaks up the leftover height. Without it the column spreads the groups out to fill the\n\t\t// panel instead of stacking them at the top.\n\t\tsidebar.Layout.AddStretchCell();\n\n\t\treturn sidebar;\n\t}\n\n\tpublic override void OnUpdate()\n\t{\n\t\t_erasing = Gizmo.IsCtrlPressed;\n\n\t\tDrawBrushPreview();\n\n\t\tGizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 999999 ) );\n\n\t\tif ( Gizmo.IsLeftMouseDown )\n\t\t{\n\t\t\tif ( !_dragging )\n\t\t\t{\n\t\t\t\t_dragging = true;\n\t\t\t\t_lastPaintPosition = Vector3.Zero;\n\t\t\t}\n\n\t\t\tOnPaintUpdate();\n\t\t}\n\t\telse if ( _dragging )\n\t\t{\n\t\t\t_dragging = false;\n\t\t\t_lastPaintPosition = Vector3.Zero;\n\n\t\t\tif ( _painted )\n\t\t\t{\n\t\t\t\tResolveTarget()?.MarkDirty();\n\t\t\t\t_painted = false;\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Uses the selected renderer if there is one, otherwise the only one in the scene. Creating\n\t/// it implicitly would leave stray components around every time someone opens the tool.\n\t/// </summary>\n\tprivate GrassRenderer ResolveTarget()\n\t{\n\t\tvar selected = Selection\n\t\t\t.OfType<GameObject>()\n\t\t\t.Select( go => go.Components.Get<GrassRenderer>( FindMode.EnabledInSelfAndDescendants ) )\n\t\t\t.FirstOrDefault( r => r.IsValid() );\n\n\t\tif ( selected.IsValid() )\n\t\t{\n\t\t\t_target = selected;\n\t\t\treturn _target;\n\t\t}\n\n\t\tif ( _target.IsValid() )\n\t\t\treturn _target;\n\n\t\t_target = Scene.GetAllComponents<GrassRenderer>().FirstOrDefault();\n\t\treturn _target;\n\t}\n\n\tprivate void OnPaintUpdate()\n\t{\n\t\tvar target = ResolveTarget();\n\t\tif ( !target.IsValid() || target.Storage is null )\n\t\t\treturn;\n\n\t\tvar cursor = TraceCursor();\n\t\tif ( !cursor.Hit )\n\t\t\treturn;\n\n\t\tif ( _lastPaintPosition != Vector3.Zero &&\n\t\t\t Vector3.DistanceBetween( cursor.HitPosition, _lastPaintPosition ) < PaintStepDistance )\n\t\t\treturn;\n\n\t\t_lastPaintPosition = cursor.HitPosition;\n\n\t\tvar radius = (float)BrushSettings.Size;\n\t\tvar strength = BrushSettings.Opacity;\n\n\t\tif ( _erasing )\n\t\t{\n\t\t\ttarget.Storage.Erase( cursor.HitPosition, radius, strength );\n\t\t\t_painted = true;\n\t\t\treturn;\n\t\t}\n\n\t\tPaintCells( target, cursor.HitPosition, radius, strength );\n\t\t_painted = true;\n\t}\n\n\t/// <summary>\n\t/// Walks every density cell the brush touches and traces straight down onto the world to bake\n\t/// the surface height and normal. Blades then follow whatever they were painted onto.\n\t/// </summary>\n\tprivate void PaintCells( GrassRenderer target, Vector3 center, float radius, float strength )\n\t{\n\t\tvar radiusSq = radius * radius;\n\n\t\tvar minX = (int)MathF.Floor( (center.x - radius) / GrassStorage.CellSize );\n\t\tvar maxX = (int)MathF.Floor( (center.x + radius) / GrassStorage.CellSize );\n\t\tvar minY = (int)MathF.Floor( (center.y - radius) / GrassStorage.CellSize );\n\t\tvar maxY = (int)MathF.Floor( (center.y + radius) / GrassStorage.CellSize );\n\n\t\t// Enough headroom to find the surface from above without punching through overhangs the\n\t\t// brush was never aimed at.\n\t\tvar traceHeight = radius + 512.0f;\n\n\t\tfor ( var cy = minY; cy <= maxY; cy++ )\n\t\t{\n\t\t\tfor ( var cx = minX; cx <= maxX; cx++ )\n\t\t\t{\n\t\t\t\tvar wx = (cx + 0.5f) * GrassStorage.CellSize;\n\t\t\t\tvar wy = (cy + 0.5f) * GrassStorage.CellSize;\n\n\t\t\t\tvar dx = wx - center.x;\n\t\t\t\tvar dy = wy - center.y;\n\t\t\t\tvar distSq = dx * dx + dy * dy;\n\t\t\t\tif ( distSq > radiusSq )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar from = new Vector3( wx, wy, center.z + traceHeight );\n\t\t\t\tvar to = new Vector3( wx, wy, center.z - traceHeight );\n\n\t\t\t\tvar tr = Scene.Trace.Ray( from, to )\n\t\t\t\t\t.UseRenderMeshes( true )\n\t\t\t\t\t.WithTag( \"solid\" )\n\t\t\t\t\t.Run();\n\n\t\t\t\tif ( !tr.Hit )\n\t\t\t\t\tcontinue;\n\n\t\t\t\t// Soft edge, so overlapping strokes build up smoothly instead of leaving a disc.\n\t\t\t\tvar falloff = 1.0f - MathF.Sqrt( distSq ) / radius;\n\t\t\t\tvar added = strength * MathF.Pow( falloff, 0.5f );\n\n\t\t\t\tvar existing = target.Storage.GetCell( wx, wy ).Density;\n\t\t\t\tvar density = Math.Clamp( existing + added, 0.0f, 1.0f );\n\n\t\t\t\ttarget.Storage.SetCell( wx, wy, density, tr.HitPosition.z, tr.Normal );\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate SceneTraceResult TraceCursor() =>\n\t\tScene.Trace.Ray( Gizmo.CurrentRay, 100000 )\n\t\t\t.UseRenderMeshes( true )\n\t\t\t.WithTag( \"solid\" )\n\t\t\t.Run();\n\n\tprivate void DrawBrushPreview()\n\t{\n\t\tvar tr = TraceCursor();\n\t\tif ( !tr.Hit )\n\t\t\treturn;\n\n\t\tusing ( Gizmo.Scope( \"GrassBrush\" ) )\n\t\t{\n\t\t\tGizmo.Draw.Color = _erasing\n\t\t\t\t? Color.FromBytes( 250, 150, 150 )\n\t\t\t\t: Color.FromBytes( 150, 250, 160 );\n\n\t\t\tGizmo.Draw.LineCircle( tr.HitPosition + tr.Normal * 1.0f, tr.Normal, BrushSettings.Size );\n\t\t\tGizmo.Draw.LineCircle( tr.HitPosition + tr.Normal * 1.0f, tr.Normal, BrushSettings.Size * 0.5f );\n\t\t}\n\t}\n}\n"
}
]
}