A s&box Component that generates, buckets and GPU-instanced-renders a large rectangular grass field. It raycasts down to place blades, applies filtering (tags, slope, exclusion volumes), groups blades into spatial chunks with model/LOD segments, and issues DrawModelInstanced calls; it also provides editor gizmo trace visualization and a fallback built-in blade mesh and material.
using Sandbox;
using System;
using System.Collections.Generic;
namespace OpenGrass;
/// <summary>
/// Standalone GPU-instanced grass field for s&box.
///
/// Drops grass blades by raycasting downward across a rectangular area, groups
/// them into spatial chunks, and renders each chunk via DrawModelInstanced
/// with distance-based LOD, frustum culling, and a simpler far mesh.
///
/// By default it builds an X-shaped two-quad mesh, but you can plug a list of
/// <see cref="CustomModels"/> (and optional <see cref="CustomModelsSimple"/> for
/// far distances) — each blade picks one deterministically from <see cref="Seed"/>,
/// giving variety. Author custom models 1 unit tall and 1 unit wide; they get
/// scaled per-blade by <see cref="Width"/> / <see cref="Height"/>.
///
/// Self-contained: no external dependencies. Drop into any s&box project.
/// </summary>
public sealed class GrassField : Component
{
// === Field ===
/// <summary> Field size in world units (X, Y). Centered on the GameObject. </summary>
[Property, Category( "Field" )]
public Vector2 FieldSize { get; set; } = new( 45000, 45000 );
/// <summary> Blades per square unit (before slope/tag/exclusion filtering). </summary>
[Property, Category( "Field" ), Range( 0.1f, 10f )]
public float Density { get; set; } = 2f;
/// <summary> Random seed — same value gives the same layout. </summary>
[Property, Category( "Field" )]
public int Seed { get; set; } = 12345;
// === Mesh ===
/// <summary>
/// Material used for the built-in mesh. Should support vertex colors and ideally
/// alpha-test cutout (g_flAlphaTestReference). If null, a plain fallback is created
/// so the component still renders something — assign your own for an actual grass look.
/// </summary>
[Property, Category( "Mesh" )]
public Material GrassMaterial { get; set; }
/// <summary>
/// Optional custom meshes used in place of the built-in two-quad blade. If more than one
/// is supplied, each blade picks one deterministically from <see cref="Seed"/>, giving you
/// variety (different shapes / sizes / textures) at the cost of one extra DrawModelInstanced
/// per used model per chunk. Empty list = built-in mesh for every blade.
/// Author each model 1×1×1 units — they are scaled per-blade by Width/Height.
/// </summary>
[Property, Category( "Mesh" )]
public List<Model> CustomModels { get; set; } = new();
/// <summary>
/// Optional simpler meshes for far-distance LOD, parallel to <see cref="CustomModels"/>.
/// Index N here is the simple variant for index N of CustomModels. Holes (null entries
/// or shorter list) fall back to the corresponding CustomModels entry, then the built-in.
/// </summary>
[Property, Category( "Mesh" )]
public List<Model> CustomModelsSimple { get; set; } = new();
// === Blade ===
[Property, Category( "Blade" ), Range( 1f, 200f )]
public float Height { get; set; } = 1f;
[Property, Category( "Blade" ), Range( 1f, 100f )]
public float Width { get; set; } = 1f;
/// <summary> Per-blade ±variation of Height/Width (0 = uniform, 1 = up to ±100%). </summary>
[Property, Category( "Blade" ), Range( 0f, 1f )]
public float HeightVariation { get; set; } = 0.5f;
/// <summary> Vertex color at the base of the built-in blade. Ignored when CustomModels is non-empty. </summary>
[Property, Category( "Blade" )]
public Color BottomColor { get; set; } = Color.White;
/// <summary> Vertex color at the tip of the built-in blade. Ignored when CustomModels is non-empty. </summary>
[Property, Category( "Blade" )]
public Color TipColor { get; set; } = Color.White;
/// <summary> Tilt blades to match the surface normal (slopes follow the ground). </summary>
[Property, Category( "Blade" )]
public bool AlignToSurface { get; set; } = true;
// === Performance ===
/// <summary> Hard cap on blade count — guards against absurd densities. </summary>
[Property, Category( "Performance" ), Range( 100, 2_000_000 )]
public int MaxGrassCount { get; set; } = 150_000;
/// <summary> Spatial chunk size. Smaller = finer culling but more draw calls. </summary>
[Property, Category( "Performance" ), Range( 50f, 500f )]
public float ChunkSize { get; set; } = 500f;
/// <summary> Chunks past this distance from the camera are not rendered. </summary>
[Property, Category( "Performance" ), Range( 100f, 5000f )]
public float RenderDistance { get; set; } = 4500f;
[Property, Category( "Performance" )]
public bool UseLOD { get; set; } = true;
/// <summary> Within this distance, 100% of blades render. </summary>
[Property, Category( "Performance" ), Range( 100f, 2000f )]
public float LOD0Distance { get; set; } = 2000f;
/// <summary> Up to this distance, ~50% of blades render. </summary>
[Property, Category( "Performance" ), Range( 200f, 3000f )]
public float LOD1Distance { get; set; } = 3000f;
/// <summary> Past this distance, ~25% of blades render. </summary>
[Property, Category( "Performance" ), Range( 300f, 4000f )]
public float LOD2Distance { get; set; } = 4000f;
/// <summary> Skip chunks behind the camera (with margin for peripheral vision). </summary>
[Property, Category( "Performance" )]
public bool UseFrustumCulling { get; set; } = true;
/// <summary> Chunks closer than this never get frustum-culled (avoids popping when turning). </summary>
[Property, Category( "Performance" ), Range( 100f, 2000f )]
public float FrustumCullMinDistance { get; set; } = 800f;
/// <summary> Past this distance, render the simpler (1-quad / CustomModelsSimple) variant. </summary>
[Property, Category( "Performance" ), Range( 500f, 4000f )]
public float SimpleMeshDistance { get; set; } = 3000f;
/// <summary> Cast shadows. Very expensive at high blade counts — leave off unless you need it. </summary>
[Property, Category( "Performance" )]
public bool CastShadows { get; set; } = false;
// === Filtering ===
/// <summary> Required tag on the surface object. Empty = any object. </summary>
[Property, Category( "Filtering" )]
public string RequiredTag { get; set; } = "";
/// <summary> Tags that block grass spawning AND are skipped during the trace. </summary>
[Property, Category( "Filtering" )]
public List<string> IgnoredTags { get; set; } = new();
/// <summary> Tag for player colliders — skipped during the ground trace. Empty disables. </summary>
[Property, Category( "Filtering" )]
public string PlayerTag { get; set; } = "player";
/// <summary> Maximum surface slope (degrees) that still spawns grass. </summary>
[Property, Category( "Filtering" ), Range( 0f, 90f )]
public float MaxSlopeAngle { get; set; } = 45f;
/// <summary> Trigger volumes with any of these tags carve out "no grass" zones (paths, roads, ...). </summary>
[Property, Category( "Filtering" )]
public List<string> ExclusionZoneTags { get; set; } = new() { "nograss" };
// === Trace ===
/// <summary> Trace start height, relative to the GameObject's WorldPosition. </summary>
[Property, Category( "Trace" ), Range( -4000f, 4000f )]
public float TraceStartHeight { get; set; } = 0f;
/// <summary> Trace end height, relative to the GameObject's WorldPosition. </summary>
[Property, Category( "Trace" ), Range( -4000f, 4000f )]
public float TraceEndHeight { get; set; } = -2000f;
// === Buttons ===
[Button( "Regenerate" )]
public void Regenerate()
{
_isDirty = true;
_gizmoTracesDirty = true;
}
[Button( "Refresh Gizmo Traces" ), Category( "Debug" )]
public void RefreshGizmoTraces() => _gizmoTracesDirty = true;
// === Public runtime API ===
/// <summary>
/// Apply a runtime tint via the material's g_vColorTint parameter.
/// Useful for day/night, biome blends, or ambient zone effects driven by other systems.
/// Only affects the auto-created fallback material — when you assign your own
/// <see cref="GrassMaterial"/>, drive its parameters yourself.
/// </summary>
public void SetTint( Color tint )
{
_ownedMaterial?.Set( "g_vColorTint", tint );
}
// === Internals ===
private Model _builtInModel;
private Model _builtInModelSimple;
private Material _ownedMaterial;
private SceneCustomObject _sceneObject;
private Transform[] _allTransforms;
private readonly List<GrassChunk> _chunks = new();
private readonly Dictionary<(int, int), int> _chunkGrid = new();
private readonly List<CachedGrassData> _cachedGrassData = new();
private bool _isDirty = true;
private readonly List<GizmoTraceData> _gizmoTraces = new();
private bool _gizmoTracesDirty = true;
private struct CachedGrassData
{
public Vector3 Position;
public Vector3 SurfaceNormal;
public float Height;
public float Width;
public float Yaw;
// 0 = renders on every LOD, 1 = LOD0+LOD1, 2 = LOD0 only.
// Sorting blades by (ModelIndex, LodGroup) lets us draw each (model, LOD)
// run as one contiguous instanced call.
public byte LodGroup;
public byte ModelIndex; // index into CustomModels, or 0 when using built-in
}
private struct ModelSegment
{
public int ModelIndex;
public int StartIndex;
public int Lod0Count; // all blades for this model — drawn at LOD0
public int Lod1Count; // ~50% — drawn at LOD1
public int Lod2Count; // ~25% — drawn at LOD2 and beyond
}
private struct GrassChunk
{
public Vector3 Center;
public BBox Bounds;
// One entry per ModelIndex used in this chunk. Each segment is one DrawModelInstanced.
public ModelSegment[] Segments;
}
private struct GizmoTraceData
{
public Vector3 StartPos;
public Vector3 HitPos;
public Color Color;
public string DebugInfo;
}
protected override void OnEnabled()
{
EnsureMeshes();
CreateSceneObject();
RegenerateGrass();
_gizmoTracesDirty = true;
}
protected override void OnDisabled()
{
_sceneObject?.Delete();
_sceneObject = null;
}
protected override void OnUpdate()
{
if ( _isDirty )
{
RegenerateGrass();
_isDirty = false;
}
}
private void EnsureMeshes()
{
// Always build the built-in pair — it's the universal fallback for null entries
// or holes in CustomModels / CustomModelsSimple. Cheap (two tiny meshes).
var material = GrassMaterial ?? CreateFallbackMaterial();
_builtInModel = BuildBuiltInModel( material, twoQuads: true );
_builtInModelSimple = BuildBuiltInModel( material, twoQuads: false );
}
private Material CreateFallbackMaterial()
{
_ownedMaterial = Material.Create( $"opengrass_fallback_{GetHashCode()}", "shaders/complex.shader" );
_ownedMaterial.Set( "g_vColorTint", Color.White );
return _ownedMaterial;
}
private Model BuildBuiltInModel( Material material, bool twoQuads )
{
const float w = 0.5f;
const float h = 1.0f;
var vb = new VertexBuffer();
vb.Init( true );
AddBuiltInQuad( vb, new Vector3( -w, 0, 0 ), new Vector3( w, 0, 0 ), new Vector3( w, 0, h ), new Vector3( -w, 0, h ) );
if ( twoQuads )
AddBuiltInQuad( vb, new Vector3( 0, -w, 0 ), new Vector3( 0, w, 0 ), new Vector3( 0, w, h ), new Vector3( 0, -w, h ) );
var mesh = new Mesh( material );
mesh.CreateBuffers( vb );
return Model.Builder.AddMesh( mesh ).Create();
}
private void AddBuiltInQuad( VertexBuffer vb, Vector3 a, Vector3 b, Vector3 c, Vector3 d )
{
var normal = Vector3.Cross( b - a, d - a ).Normal;
var tangent = (b - a).Normal;
// Front face
vb.Add( new Vertex( a, normal, tangent, new Vector2( 0, 1 ) ) { Color = BottomColor } );
vb.Add( new Vertex( b, normal, tangent, new Vector2( 1, 1 ) ) { Color = BottomColor } );
vb.Add( new Vertex( c, normal, tangent, new Vector2( 1, 0 ) ) { Color = TipColor } );
vb.Add( new Vertex( d, normal, tangent, new Vector2( 0, 0 ) ) { Color = TipColor } );
vb.AddTriangleIndex( 4, 3, 2 );
vb.AddTriangleIndex( 2, 1, 4 );
// Back face — blades shouldn't disappear from behind
vb.Add( new Vertex( b, -normal, tangent, new Vector2( 1, 1 ) ) { Color = BottomColor } );
vb.Add( new Vertex( a, -normal, tangent, new Vector2( 0, 1 ) ) { Color = BottomColor } );
vb.Add( new Vertex( d, -normal, tangent, new Vector2( 0, 0 ) ) { Color = TipColor } );
vb.Add( new Vertex( c, -normal, tangent, new Vector2( 1, 0 ) ) { Color = TipColor } );
vb.AddTriangleIndex( 4, 3, 2 );
vb.AddTriangleIndex( 2, 1, 4 );
}
private void CreateSceneObject()
{
if ( !Scene.IsValid() ) return;
_sceneObject = new SceneCustomObject( Scene.SceneWorld )
{
RenderOverride = RenderGrass,
Transform = WorldTransform,
};
_sceneObject.Flags.CastShadows = CastShadows;
_sceneObject.Flags.IsOpaque = true;
_sceneObject.Flags.IsTranslucent = false;
}
private void RegenerateGrass()
{
if ( !Scene.IsValid() ) return;
var random = new Random( Seed );
int potentialCount = (int)(FieldSize.x * FieldSize.y * Density);
potentialCount = Math.Min( potentialCount, MaxGrassCount );
float halfX = FieldSize.x * 0.5f;
float halfY = FieldSize.y * 0.5f;
float maxSlopeCos = MathF.Cos( MaxSlopeAngle * MathF.PI / 180f );
_cachedGrassData.Clear();
// 1 when no custom models — every blade uses the built-in mesh (ModelIndex 0).
int modelCount = Math.Max( 1, CustomModels?.Count ?? 0 );
for ( int i = 0; i < potentialCount; i++ )
{
float x = (float)(random.NextDouble() * 2 - 1) * halfX;
float y = (float)(random.NextDouble() * 2 - 1) * halfY;
var startPos = WorldPosition + new Vector3( x, y, TraceStartHeight );
var endPos = WorldPosition + new Vector3( x, y, TraceEndHeight );
var trace = BuildGroundTrace( startPos, endPos ).Run();
if ( !trace.Hit ) continue;
if ( !IsValidSurface( trace ) ) continue;
float dotUp = Vector3.Dot( trace.Normal, Vector3.Up );
if ( dotUp < maxSlopeCos ) continue;
if ( IsInExclusionZone( trace.EndPosition ) ) continue;
float scaleVar = 1f + (float)(random.NextDouble() * 2 - 1) * HeightVariation;
float yaw = (float)(random.NextDouble() * 360);
byte modelIndex = (byte)random.Next( modelCount );
// 25% group 0 (LOD0+LOD1+LOD2), 25% group 1 (LOD0+LOD1), 50% group 2 (LOD0 only).
byte lodGroup = (byte)(i % 4);
if ( lodGroup >= 2 ) lodGroup = 2;
_cachedGrassData.Add( new CachedGrassData
{
Position = trace.EndPosition,
SurfaceNormal = trace.Normal,
Height = Height * scaleVar,
Width = Width * scaleVar,
Yaw = yaw,
LodGroup = lodGroup,
ModelIndex = modelIndex,
} );
}
BuildChunks();
}
private SceneTrace BuildGroundTrace( Vector3 from, Vector3 to )
{
var t = Scene.Trace.Ray( from, to );
if ( !string.IsNullOrEmpty( PlayerTag ) )
t = t.WithoutTags( PlayerTag );
if ( IgnoredTags != null )
{
foreach ( var tag in IgnoredTags )
{
if ( !string.IsNullOrEmpty( tag ) )
t = t.WithoutTags( tag );
}
}
return t;
}
private bool IsValidSurface( SceneTraceResult trace )
{
if ( trace.GameObject == null ) return false;
var tags = trace.GameObject.Tags;
if ( IgnoredTags != null )
{
foreach ( var tag in IgnoredTags )
{
if ( !string.IsNullOrEmpty( tag ) && tags.Has( tag ) )
return false;
}
}
if ( !string.IsNullOrEmpty( RequiredTag ) )
return tags.Has( RequiredTag );
return true;
}
private bool IsInExclusionZone( Vector3 position )
{
if ( ExclusionZoneTags == null || ExclusionZoneTags.Count == 0 )
return false;
// Probe up and down a short distance to catch trigger volumes that cross the surface.
if ( ExclusionZoneTrace( position, position + Vector3.Up * 50f ) ) return true;
if ( ExclusionZoneTrace( position + Vector3.Up * 5f, position - Vector3.Up * 50f ) ) return true;
return false;
}
private bool ExclusionZoneTrace( Vector3 from, Vector3 to )
{
var trace = Scene.Trace.Ray( from, to ).HitTriggers();
if ( !string.IsNullOrEmpty( PlayerTag ) )
trace = trace.WithoutTags( PlayerTag );
var hit = trace.Run();
if ( !hit.Hit || hit.GameObject == null ) return false;
foreach ( var tag in ExclusionZoneTags )
{
if ( !string.IsNullOrEmpty( tag ) && hit.GameObject.Tags.Has( tag ) )
return true;
}
return false;
}
private void BuildChunks()
{
_chunks.Clear();
_chunkGrid.Clear();
if ( _cachedGrassData.Count == 0 )
{
_allTransforms = Array.Empty<Transform>();
return;
}
// Bucket blade indices by chunk grid cell.
var chunkBuckets = new Dictionary<(int, int), List<int>>();
for ( int i = 0; i < _cachedGrassData.Count; i++ )
{
var pos = _cachedGrassData[i].Position;
int cx = (int)Math.Floor( (pos.x - WorldPosition.x) / ChunkSize );
int cy = (int)Math.Floor( (pos.y - WorldPosition.y) / ChunkSize );
if ( !chunkBuckets.TryGetValue( (cx, cy), out var list ) )
chunkBuckets[(cx, cy)] = list = new List<int>();
list.Add( i );
}
_allTransforms = new Transform[_cachedGrassData.Count];
int currentIndex = 0;
var segmentScratch = new List<ModelSegment>();
foreach ( var (key, indices) in chunkBuckets )
{
// Sort blades by (ModelIndex, LodGroup) — gives us contiguous runs of
// "same model, sorted by LOD group" so each segment's first N transforms
// are exactly what we want to draw at a given LOD.
indices.Sort( ( a, b ) =>
{
var da = _cachedGrassData[a];
var db = _cachedGrassData[b];
int byModel = da.ModelIndex.CompareTo( db.ModelIndex );
return byModel != 0 ? byModel : da.LodGroup.CompareTo( db.LodGroup );
} );
var chunk = new GrassChunk();
var bounds = new BBox();
float cxSum = 0, cySum = 0, czSum = 0;
segmentScratch.Clear();
ModelSegment current = default;
int lastModelIndex = -1;
foreach ( var idx in indices )
{
var data = _cachedGrassData[idx];
Rotation rot;
if ( AlignToSurface && data.SurfaceNormal != Vector3.Up )
{
var surfaceRot = Rotation.FromToRotation( Vector3.Up, data.SurfaceNormal );
var yawRot = Rotation.FromYaw( data.Yaw );
rot = surfaceRot * yawRot;
}
else
{
rot = Rotation.FromYaw( data.Yaw );
}
var scale = new Vector3( data.Width, data.Width, data.Height );
_allTransforms[currentIndex] = new Transform( data.Position, rot, scale );
if ( data.ModelIndex != lastModelIndex )
{
if ( lastModelIndex != -1 )
segmentScratch.Add( current );
current = new ModelSegment { ModelIndex = data.ModelIndex, StartIndex = currentIndex };
lastModelIndex = data.ModelIndex;
}
if ( data.LodGroup == 0 ) current.Lod2Count++;
if ( data.LodGroup <= 1 ) current.Lod1Count++;
current.Lod0Count++;
bounds = bounds.AddPoint( data.Position );
bounds = bounds.AddPoint( data.Position + new Vector3( 0, 0, Height ) );
cxSum += data.Position.x;
cySum += data.Position.y;
czSum += data.Position.z;
currentIndex++;
}
if ( lastModelIndex != -1 )
segmentScratch.Add( current );
chunk.Segments = segmentScratch.ToArray();
chunk.Center = new Vector3( cxSum / indices.Count, cySum / indices.Count, czSum / indices.Count );
chunk.Bounds = bounds;
_chunkGrid[key] = _chunks.Count;
_chunks.Add( chunk );
}
}
private void RenderGrass( SceneObject obj )
{
if ( _allTransforms == null || _allTransforms.Length == 0 || _chunks.Count == 0 )
return;
var camera = Scene.Camera;
if ( !camera.IsValid() ) return;
var cameraPos = camera.WorldPosition;
var cameraForward = camera.WorldRotation.Forward;
float renderDistSq = RenderDistance * RenderDistance;
float lod0DistSq = LOD0Distance * LOD0Distance;
float lod1DistSq = LOD1Distance * LOD1Distance;
float simpleMeshDistSq = SimpleMeshDistance * SimpleMeshDistance;
float frustumMinDistSq = FrustumCullMinDistance * FrustumCullMinDistance;
// Iterate only the chunks within RenderDistance of the camera (square around camera cell).
int chunksInRadius = (int)MathF.Ceiling( RenderDistance / ChunkSize ) + 1;
int cameraCx = (int)MathF.Floor( (cameraPos.x - WorldPosition.x) / ChunkSize );
int cameraCy = (int)MathF.Floor( (cameraPos.y - WorldPosition.y) / ChunkSize );
for ( int dx = -chunksInRadius; dx <= chunksInRadius; dx++ )
{
for ( int dy = -chunksInRadius; dy <= chunksInRadius; dy++ )
{
if ( !_chunkGrid.TryGetValue( (cameraCx + dx, cameraCy + dy), out int chunkIndex ) )
continue;
var chunk = _chunks[chunkIndex];
float distSq = (chunk.Center - cameraPos).LengthSquared;
if ( distSq > renderDistSq ) continue;
if ( UseFrustumCulling && distSq > frustumMinDistSq )
{
var toChunk = (chunk.Center - cameraPos).Normal;
// dot < -0.2 = clearly behind the camera (margin keeps peripheral chunks visible).
if ( Vector3.Dot( cameraForward, toChunk ) < -0.2f )
continue;
}
bool useSimple = distSq > simpleMeshDistSq;
// One DrawModelInstanced per (model used in this chunk).
foreach ( var seg in chunk.Segments )
{
int renderCount;
if ( !UseLOD ) renderCount = seg.Lod0Count;
else if ( distSq <= lod0DistSq ) renderCount = seg.Lod0Count;
else if ( distSq <= lod1DistSq ) renderCount = seg.Lod1Count;
else renderCount = seg.Lod2Count;
if ( renderCount <= 0 ) continue;
var model = useSimple ? PickSimpleModel( seg.ModelIndex ) : PickFullModel( seg.ModelIndex );
if ( model == null ) continue;
var transforms = _allTransforms.AsSpan( seg.StartIndex, renderCount );
Graphics.DrawModelInstanced( model, transforms );
}
}
}
}
private Model PickFullModel( int modelIndex )
{
if ( CustomModels != null && modelIndex < CustomModels.Count && CustomModels[modelIndex] != null )
return CustomModels[modelIndex];
return _builtInModel;
}
private Model PickSimpleModel( int modelIndex )
{
// Prefer the simple variant; if missing, drop down to the full custom mesh,
// then to the built-in simple mesh.
if ( CustomModelsSimple != null && modelIndex < CustomModelsSimple.Count && CustomModelsSimple[modelIndex] != null )
return CustomModelsSimple[modelIndex];
if ( CustomModels != null && modelIndex < CustomModels.Count && CustomModels[modelIndex] != null )
return CustomModels[modelIndex];
return _builtInModelSimple ?? _builtInModel;
}
// === Gizmos: visualize trace results when this component is selected. ===
protected override void DrawGizmos()
{
if ( !Gizmo.IsSelected ) return;
if ( _gizmoTracesDirty )
{
RebuildGizmoTraces();
_gizmoTracesDirty = false;
}
var cameraPos = Gizmo.Camera.Position;
const float drawDistSq = 1000f * 1000f;
const float textDistSq = 200f * 200f;
Gizmo.Draw.LineThickness = 1f;
foreach ( var trace in _gizmoTraces )
{
var worldPos = WorldPosition + trace.StartPos;
var distSq = (worldPos - cameraPos).LengthSquared;
if ( distSq > drawDistSq ) continue;
Gizmo.Draw.Color = trace.Color;
Gizmo.Draw.Line( trace.StartPos, trace.HitPos );
// Show why a trace was rejected (red = filtered out by IsValidSurface).
if ( trace.Color == Color.Red && distSq < textDistSq && !string.IsNullOrEmpty( trace.DebugInfo ) )
{
Gizmo.Draw.Color = Color.White;
Gizmo.Draw.ScreenText( trace.DebugInfo, WorldPosition + trace.HitPos, Vector2.Zero, "Roboto", 10 );
}
}
}
private void RebuildGizmoTraces()
{
_gizmoTraces.Clear();
var random = new Random( Seed );
int potentialCount = (int)(FieldSize.x * FieldSize.y * Density);
potentialCount = Math.Min( potentialCount, MaxGrassCount );
float halfX = FieldSize.x * 0.5f;
float halfY = FieldSize.y * 0.5f;
float maxSlopeCos = MathF.Cos( MaxSlopeAngle * MathF.PI / 180f );
for ( int i = 0; i < potentialCount; i++ )
{
float x = (float)(random.NextDouble() * 2 - 1) * halfX;
float y = (float)(random.NextDouble() * 2 - 1) * halfY;
var startLocal = new Vector3( x, y, TraceStartHeight );
var endLocal = new Vector3( x, y, TraceEndHeight );
var trace = BuildGroundTrace( WorldPosition + startLocal, WorldPosition + endLocal ).Run();
var hitLocal = trace.Hit ? trace.EndPosition - WorldPosition : endLocal;
Color color;
string debugInfo = "";
if ( !trace.Hit )
{
color = Color.Gray.WithAlpha( 0.1f );
debugInfo = "No hit";
}
else if ( !IsValidSurface( trace ) )
{
color = Color.Red;
var go = trace.GameObject;
if ( go == null )
{
debugInfo = "GameObject is NULL";
}
else
{
var tagList = string.Join( ", ", go.Tags.TryGetAll() );
debugInfo = $"GO: {go.Name}, Tags: [{tagList}], Need: {RequiredTag}";
}
}
else if ( Vector3.Dot( trace.Normal, Vector3.Up ) < maxSlopeCos )
{
color = Color.Orange;
}
else if ( IsInExclusionZone( trace.EndPosition ) )
{
color = Color.Blue;
}
else
{
color = Color.Green;
}
_gizmoTraces.Add( new GizmoTraceData
{
StartPos = startLocal,
HitPos = hitLocal,
Color = color,
DebugInfo = debugInfo,
} );
}
}
}