Component that scatters model instances across a Terrain in the editor and at runtime. It configures scatter parameters, validates inputs, builds LOD and material mappings, prepares noise fields and creates a SceneCustomObject with a custom render callback; it also manages chunk data and render caches during updates.
using System;
using System.Collections.Generic;
using Sandbox;
public sealed partial class PerformantTerrainScatterer : Component, Component.ExecuteInEditor
{
[Property, Category( "Terrain" ), Change( nameof(InitializeSystem) )]
public Terrain TargetTerrain { get; set; }
[Property, Category( "Models" ), Change( nameof(InitializeSystem) )]
public List<ScattererModelEntry> Models { get; set; } = new();
[Property, Category( "Terrain" ), Change( nameof(InitializeSystem) ), Range( 0, 90 )]
public float MaxSlopeAngle { get; set; } = 45f;
[Property, Category( "Terrain" ), Change( nameof(InitializeSystem) )]
public bool ReduceDensityOnTransitions { get; set; } = false;
[Property, Category( "Terrain" ), Change( nameof(InitializeSystem) ),
ShowIf( nameof(ReduceDensityOnTransitions), true )]
public float TransitionThinningIntensity { get; set; } = 1.0f;
[Property, Category( "Terrain" ), Change( nameof(InitializeSystem) )]
public bool SnapToGround { get; set; } = true;
[Property, Category( "Collision" ), Change( nameof(InitializeSystem) )]
public bool CheckObstructions { get; set; } = true;
[Property, Category( "Collision" ), Change( nameof(InitializeSystem) )]
public float TraceStartHeight { get; set; } = 200f;
[Property, Category( "Collision" ), Change( nameof(InitializeSystem) )]
public float GridCellSize { get; set; } = 50f;
[Property, Category( "Collision" ), Change( nameof(InitializeSystem) )]
public int ObstructionBuffer { get; set; } = 2;
[Property, Category( "Collision" )]
public string[] TraceIgnoreTags { get; set; } = { "player", "trigger", "clutter" };
[Property, Category( "Performance" ), Change( nameof(InitializeSystem) )]
public int MaxInstancesPerChunk { get; set; } = 500;
[Property, Category( "Performance" ), Change( nameof(InitializeSystem) )]
public List<MaterialInstanceOverride> MaterialOverrides { get; set; } = new();
[Property, Category( "Performance" ), Change( nameof(InitializeSystem) )]
public float ChunkSize { get; set; } = 500f;
[Property, Category( "Performance" ), Change( nameof(InitializeSystem) )]
public float ChunkLoadDistance { get; set; } = 3000f;
[Property, Category( "Performance" )] public bool UseFrustumCulling { get; set; } = true;
[Property, Category( "Performance" )] public float FrustumCullMinDistance { get; set; } = 800f;
[Property, Category( "Performance" )] public bool AutomaticallyInitializeSystem { get; set; } = true;
[Property, Category( "Terrain" ), Change( nameof(InitializeSystem) )]
public bool UseTerrainNormal { get; set; } = true;
[Property, Category( "Seed" ), Change( nameof(InitializeSystem) )]
public int Seed { get; set; } = 12345;
[Property, Category( "Randomization" ), Change( nameof(InitializeSystem) )]
public bool RandomizeRotation { get; set; } = true;
private readonly Dictionary<(int, int), ClutterChunk> _activeChunks = new();
private readonly HashSet<(int, int)> _generatingChunks = new();
private readonly List<(int, int)> _chunkRemovalList = new();
private ushort[] _heightMapPixels;
private uint[] _controlMapPixels;
private int _resolution;
private float _terrainSize;
private float _terrainHeight;
private float _maxRenderDistance;
private float[][] _modelMaterialWeights;
private GameObject _cachedHitObject;
private bool _cachedIsObstruction;
private Sandbox.Utility.INoiseField[] _modelNoiseFields;
private Vector3 _lastCameraPos;
private const float CameraUpdateThresholdSq = 10000f;
private const float UshortMaxReciprocal = 1.0f / ushort.MaxValue;
private SceneCustomObject _sceneObject;
private ScattererModelEntry[] ModelEntries { get; set; }
private float[] _modelRenderDistSq;
private int[] _modelLodCounts;
private float[][] _modelLodDistancesSq;
private int[] _materialMaxInstances;
private int _maxInstancesLoopCount;
private ChunkRenderData[] _renderCache = Array.Empty<ChunkRenderData>();
[ThreadStatic] private static List<Transform>[] _lodBuckets;
protected override void OnEnabled()
{
if ( !TargetTerrain.IsValid() )
{
TargetTerrain = Scene?.GetComponentInChildren<Terrain>();
}
CreateSceneObject();
if ( AutomaticallyInitializeSystem )
{
InitializeSystem();
}
}
protected override void OnUpdate()
{
ManageChunks();
}
protected override void OnDisabled()
{
_sceneObject?.Delete();
_sceneObject = null;
}
private void CreateSceneObject()
{
_sceneObject?.Delete();
if ( Scene == null ) return;
_sceneObject = new SceneCustomObject( Scene.SceneWorld )
{
RenderOverride = RenderClutter,
Transform = new Transform( Vector3.Zero, Rotation.Identity, 1f ),
Flags = { IsOpaque = true, IsTranslucent = false }
};
}
[Button( "Reinitialize" )]
public void InitializeSystem()
{
_activeChunks.Clear();
_generatingChunks.Clear();
_chunkRemovalList.Clear();
_renderCache = Array.Empty<ChunkRenderData>();
ModelEntries = null;
_modelRenderDistSq = null;
_modelLodCounts = null;
_modelLodDistancesSq = null;
if ( Models == null || Models.Count == 0 ) return;
if ( !TargetTerrain.IsValid() || TargetTerrain.Storage == null ) return;
// --- CONFIGURATION VALIDATION WARNINGS ---
var seenModels = new HashSet<Model>();
foreach ( var entry in Models )
{
if ( entry.Model != null && !seenModels.Add( entry.Model ) )
{
Log.Warning( $"[Scatterer] Duplicate model entry found for '{entry.Model.ResourceName}' in the Models configuration list." );
}
}
if ( MaterialOverrides != null )
{
var seenOverrideMaterials = new HashSet<TerrainMaterial>();
foreach ( var ov in MaterialOverrides )
{
if ( ov.Material != null && !seenOverrideMaterials.Add( ov.Material ) )
{
Log.Warning( $"[Scatterer] Duplicate material override configuration found for material '{ov.Material.ResourceName}'." );
}
}
}
bool hasValidModel = false;
_maxRenderDistance = ChunkLoadDistance;
foreach ( var t in Models )
{
if ( t.Model != null )
{
var seenMaterials = new HashSet<TerrainMaterial>();
bool hasWeight = false;
// MISSING NULL CHECK FIXED HERE:
if ( t.MaterialWeights != null )
{
foreach ( var mw in t.MaterialWeights )
{
if ( mw.Material == null ) continue;
if ( !seenMaterials.Add( mw.Material ) )
Log.Warning(
$"[Scatterer] Duplicate material entry '{mw.Material.ResourceName}' found for model '{t.Model.ResourceName}'." );
if ( mw.Weight <= 0f )
Log.Warning(
$"[Scatterer] Weight is set to 0 for material '{mw.Material.ResourceName}' on model '{t.Model.ResourceName}'." );
if ( mw.Weight > 0f ) hasWeight = true;
}
}
if ( hasWeight ) hasValidModel = true;
if ( t.RenderDistance > _maxRenderDistance )
_maxRenderDistance = t.RenderDistance;
}
}
if ( !hasValidModel ) return;
ModelEntries = Models.ToArray();
_modelRenderDistSq = new float[ModelEntries.Length];
_modelLodCounts = new int[ModelEntries.Length];
_modelLodDistancesSq = new float[ModelEntries.Length][];
for ( int m = 0; m < ModelEntries.Length; m++ )
{
_modelRenderDistSq[m] = ModelEntries[m].RenderDistance * ModelEntries[m].RenderDistance;
var mdl = ModelEntries[m].Model;
if ( mdl != null )
{
_modelLodCounts[m] = Math.Max( 1, mdl.MeshInfo.LodCount );
if ( mdl.MeshInfo.LodSwitchDistances != null && mdl.MeshInfo.LodSwitchDistances.Length > 0 &&
mdl.MeshInfo.LodSwitchDistances[0] > 0f )
{
var switchDistances = mdl.MeshInfo.LodSwitchDistances;
_modelLodDistancesSq[m] = new float[switchDistances.Length];
for ( int i = 0; i < switchDistances.Length; i++ )
{
float dist = switchDistances[i] * ModelEntries[m].LodBias;
_modelLodDistancesSq[m][i] = dist * dist;
}
}
else if ( _modelLodCounts[m] > 1 )
{
int switchCount = _modelLodCounts[m] - 1;
_modelLodDistancesSq[m] = new float[switchCount];
float step = ModelEntries[m].RenderDistance / _modelLodCounts[m];
for ( int i = 0; i < switchCount; i++ )
{
float dist = (step * (i + 1)) * ModelEntries[m].LodBias;
_modelLodDistancesSq[m][i] = dist * dist;
}
}
else
{
_modelLodDistancesSq[m] = Array.Empty<float>();
}
}
else
{
_modelLodCounts[m] = 1;
_modelLodDistancesSq[m] = Array.Empty<float>();
}
}
_heightMapPixels = TargetTerrain.Storage.HeightMap;
_controlMapPixels = TargetTerrain.Storage.ControlMap;
_resolution = TargetTerrain.Storage.Resolution;
_terrainSize = TargetTerrain.TerrainSize;
_terrainHeight = TargetTerrain.TerrainHeight;
_modelMaterialWeights = new float[Models.Count][];
for ( int m = 0; m < Models.Count; m++ )
{
_modelMaterialWeights[m] = new float[32];
if ( TargetTerrain.Storage.Materials != null && Models[m].MaterialWeights != null )
{
for ( int i = 0; i < TargetTerrain.Storage.Materials.Count; i++ )
{
foreach ( var mw in Models[m].MaterialWeights )
{
if ( mw.Material == TargetTerrain.Storage.Materials[i] )
{
_modelMaterialWeights[m][i] = mw.Weight;
break;
}
}
}
}
}
_modelNoiseFields = new Sandbox.Utility.INoiseField[Models.Count];
for ( int m = 0; m < Models.Count; m++ )
{
var modelEntry = Models[m];
if ( modelEntry.NoiseType == ScattererNoiseType.None ) continue;
var parameters = new Sandbox.Utility.Noise.Parameters( Seed + m, modelEntry.NoiseScale );
if ( modelEntry.NoiseType == ScattererNoiseType.Perlin )
_modelNoiseFields[m] = Sandbox.Utility.Noise.PerlinField( parameters );
else if ( modelEntry.NoiseType == ScattererNoiseType.Simplex )
_modelNoiseFields[m] = Sandbox.Utility.Noise.SimplexField( parameters );
}
// --- MAPPING AND CEILING INITIALIZATION ---
_materialMaxInstances = new int[32];
Array.Fill( _materialMaxInstances, MaxInstancesPerChunk );
_maxInstancesLoopCount = MaxInstancesPerChunk;
if ( TargetTerrain.Storage.Materials != null && MaterialOverrides != null )
{
for ( int i = 0; i < TargetTerrain.Storage.Materials.Count; i++ )
{
foreach ( var ov in MaterialOverrides )
{
if ( ov.Material == TargetTerrain.Storage.Materials[i] )
{
_materialMaxInstances[i] = ov.MaxInstances;
if ( ov.MaxInstances > _maxInstancesLoopCount )
{
_maxInstancesLoopCount = ov.MaxInstances;
}
break;
}
}
}
}
RebuildRenderCache();
}
}