Figure 1: Assets used in this article, coming from the great Raygeas grassland pack

Figure 2: Poisson disk distribution over a plane. Each tree is a disk that cannot overlap with any other one.

Figure 3: Two poisson disk distributions with different radii. From afar, it looks like it is working as intended. But up close, we observe collisions between the rocks and the trees, which feels a little unnatural. Figure 3: Two poisson disk distributions with different radii. From afar, it looks like it is working as intended. But up close, we observe collisions between the rocks and the trees, which feels a little unnatural.

Figure 5: Multiclass Poisson disk distribution over a plane, from Wei et al. [6].

Figure 6: Slope-based filtering, without (left) and with (right). Having trees that avoid steep slopes makes the scene more natural. Figure 6: Slope-based filtering, without (left) and with (right). Having trees that avoid steep slopes makes the scene more natural.

Figure 7: The above scattering rules in action on a real island. Figure 7: The above scattering rules in action on a real island.

Figure 8: Forest stratification, from Wikipedia [7]. In Biology, stratifications classify plants into different layers based on their height and vertical distribution.

Figure 8: Hierarchical scattering within a disk around the primary sample. Figure 8: Hierarchical scattering within a disk around the primary sample.

Figure 9: Full island with hierarchical scattering in place. Figure 9: Full island with hierarchical scattering in place. Figure 9: Full island with hierarchical scattering in place. Figure 9: Full island with hierarchical scattering in place.

public class ScatteredObjectParams
{
    public string name;

    // Prefabs, amounts
    public List<GameObject> prefabs;
    public bool randomAmount;
    public Vector2Int amountRange;

    // Secondary objects - the hierarchical aspect
    public float secondaryDiskSize;
    public List<SecondaryObjectParams> secondaryPrefabs;

    // Sample validation constraints
    public float poissonRadius;
    public BiomeType biome;
    public Vector2 altitudeRange;
    public Vector2 slopeRange;
    public float noiseThreshold;
    public float noiseFrequency;

    // Gameobject placement constraints
    public float altitudeOffset;
    public Vector2 rotationYRange;
    public Vector2 scaleRange;
}

// Could be extended if we want more complex positioning rules
public class SecondaryObjectParams
{
    public string name;
    public GameObject prefab;
    public Vector2Int amountRange;
}

public static class Scattering
{
    public static List<GameObject> ScatterObject(ScatteredObjectParams objParams, ...)
    {
      // 1. Generate a Poisson disk distribution for primary samples
      List<Vector2> primarySamples = PoissonSampling.Generate(objParams.poissonRadius);

      // 2. Validate each sample
      List<Vector2> validSamples = new List<Vector2>();
      foreach (var sample in primarySamples)
      {
        if (IsValid(sample, objParams))
          validSamples.Add(sample);
      }

      // 3. Instantiate objects
      foreach (var sample in validSamples)
      {
        GameObject obj = Instantiate(Utils.RandomElement(objParams.prefabs));
        obj.transform.position = sample;
        obj.transform.rotation = Utils.RandomRotationY(objParams.rotationYRange);
        obj.transform.localScale = Utils.RandomScale(objParams.scaleRange);

        ret.Add(obj);

        // 4. Scatter secondary objects around sample position
        if (objParams.secondaryPrefabs.Count > 0)
        {
          List<GameObject> secondaryObjects = ScatterSecondaryObjects(
            objParams.secondaryPrefabs, 
            sample,
            objParams.secondaryDiskSize
          );

          ret.AddRange(secondaryObjects);
        }
      }

      return ret;
    }

    public static bool IsValid(Vector2 sample, ScatteredObjectParams objParams, ...)
    {
      // 1. Check biome
      if (objParams.biome != terrain.GetBiome(sample.x, sample.y))
        return false;

      // 2. Check altitude
      float altitude = terrain.GetHeight(sample.x, sample.y);
      if (altitude < objParams.altitudeRange.x || altitude > objParams.altitudeRange.y) 
        return false;

      // 3. Check slope
      float slope = terrain.GetSlope(sample.x, sample.y);
      if (slope < objParams.slopeRange.x || slope > objParams.slopeRange.y) 
        return false;

      // 4. Check noise threshold
      float noise = Noise.PerlinNoise(sample.x, sample.y, objParams.noiseFrequency);
      if (noise < objParams.noiseThreshold)
        return false;

      return true;
    }

    public static List<GameObject> ScatterSecondaryObjects(
      List<SecondaryObjectParams> secondaryParams,
      ScatteredObjectParams objParams,
      Vector2 sample, 
      float diskSize, 
      ...)
    {
      List<GameObject> ret = new List<GameObject>();
      foreach (var param in secondaryParams)
      {
        int amount = Utils.RandomInt(param.amountRange.x, param.amountRange.y);
        for (int i = 0; i < amount; i++)
        {
          Vector2 secondarySample = sample + Utils.RandomVector2(diskSize);
          if (IsValid(secondarySample, objParams))
          {
            GameObject obj = Instantiate(param.prefab);
            obj.transform.position = secondarySample; 
            obj.transform.rotation = Utils.RandomRotationY(param.rotationYRange);
            obj.transform.localScale = Utils.RandomScale(param.scaleRange);
            ret.Add(obj);
          }        
        }
      }
      return ret;
    }
}