Skip to content

Level Foliage System

Optimizing large-scale vegetation rendering, grass displacement physics, and wind-animated plant life in Unturned levels begins with understanding how the GPU-instanced foliage system stores per-tile instance data, bakes procedurally, and integrates with landscape tile coordinates. The foliage system (SDG.Framework.Foliage) operates at the landscape level, independent of the Level subclasses. It stores per-tile foliage instance data, supports GPU-instanced rendering, wind interaction, LOD groups, and collision detection. LevelGround.handlePreBakeTile provides the bridge between LevelGround's resource trees and the foliage bake pipeline — ensuring generated trees do not duplicate with baked foliage.

Source location: SDG.Framework.Foliage namespace, Unturned/Level/LevelGround.cs (handlePreBakeTile method)

Architecture Overview

The foliage system is part of the SDG.Framework.Foliage namespace, separate from the Unturned.Level subclasses. It operates on landscape tiles, each with an associated FoliageTile that stores per-instance foliage data. The system bakes foliage at edit time and renders it at runtime using GPU instancing for efficient rendering of thousands of individual grass/plant instances.

Key architectural components:

  • FoliageTile: Per-tile data container for foliage instances.
  • FoliageBakeSettings: Configuration struct controlling the bake process.
  • FoliageSystem: Central manager that stores tile data in Environment/Foliage/ directory.
  • GPU instancing: Efficient batch rendering for grass and plant instances.
  • Wind integration: WindZone component plus LevelLighting.wind for animated sway.

FoliageTile

Each landscape tile has an associated FoliageTile that stores per-instance foliage data:

  • Instance position, rotation, and scale: Per-instance transform data.
  • Foliage asset reference: Material, mesh, density parameters.
  • Generated vs. hand-placed flag: Whether the instance was baked procedurally or placed manually.

Tiles are organized in the same grid as landscape tiles, ensuring 1:1 correspondence between terrain patches and their foliage.

FoliageBakeSettings

csharp
public struct FoliageBakeSettings
{
    public bool bakeResources;
    public bool bakeObjects;
    // ...
}
FieldPurpose
bakeResourcesWhether to remove resource trees within the tile's bounds before baking
bakeObjectsWhether to bake object-aligned foliage

When a foliage bake is triggered, the bakeResources flag controls whether resource trees within the tile's bounds are removed before baking. This prevents doubling up on trees — if a resource tree was procedurally placed by LevelGround, the bake will remove it before generating foliage.

LevelGround.handlePreBakeTile

csharp
protected static void handlePreBakeTile(FoliageBakeSettings bakeSettings, FoliageTile foliageTile)

Called before foliage tile baking. This bridge method removes procedurally generated trees that overlap with the foliage tile's bounds:

  1. Receives the FoliageBakeSettings and the target FoliageTile.
  2. If bakeResources is true, finds all ResourceSpawnpoint instances within the tile's world bounds.
  3. Calls destroy() on each tree's model, stump, and skybox transforms.
  4. Removes each tree from the _regionTrees dictionary.
  5. Releases empty region lists.

This ensures procedurally placed trees (from LevelGround.addSpawn) are replaced by the foliage bake rather than creating visual duplicates.

Foliage Rendering

GPU Instancing

Foliage uses GPU instancing for efficient rendering of thousands of individual grass/plant instances. The system supports:

  • Wind interaction: Via WindZone and LevelLighting.wind — foliage sways in response to wind strength and direction.
  • LOD groups: Three levels (near, medium, far) control rendering detail at different distances.
  • Collision detection: Gameplay interaction — player pushes through grass, grass reacts to player movement.

Wind Animation

The wind system drives foliage animation:

  • Tree and foliage animation (swaying) at varying amplitudes.
  • Particle system velocity modifications.
  • Cloud movement speed integration.
  • Audio ambiance (wind sound volume).

The WindZone component applies wind to Unity's built-in wind-affected shaders and the landscape foliage system.

Collision Detection

Foliage supports collision detection for gameplay interaction — the player character can push through grass, with the grass visually responding to the collision. This requires foliage instances to have collision detection enabled.

Foliage Data Storage

Level foliage data is saved and loaded through the FoliageSystem which stores FoliageTile data in the Environment/Foliage/ directory. Each tile's data includes:

  • Foliage instance transforms.
  • Asset references (material, mesh, density).
  • Instance flags (generated vs. hand-placed).

The data is serialized in a binary format compatible with the landscape tile coordinate system.

Grass Displacement

csharp
// GrassDisplacement.cs

A component in Unturned/Level/GrassDisplacement.cs handles grass and detail mesh displacement when the player walks over foliage. This provides the visual feedback of grass flattening under the player's feet.

Foliage and Terrain Details

The Terrain/Details.unity3d and Terrain/Details.dat files store detail object configuration:

  • Grass and detail mesh prefabs.
  • Detail density per region.
  • Detail render distance.

These are separate from the FoliageTile data — they represent the old Unity Terrain detail system, which the foliage system supersedes.

Foliage Baking Workflow

  1. Editor triggers bake: Map author initiates foliage generation.
  2. Tile selection: The foliage system determines which tiles to bake based on changed regions.
  3. Pre-bake callback: LevelGround.handlePreBakeTile removes conflicting resource trees.
  4. Procedural placement: Foliage instances are generated based on terrain material, slope, and biome rules.
  5. Data serialization: Each tile's foliage data is written to Environment/Foliage/.
  6. Runtime loading: During level load, foliage tiles are loaded and instantiated with GPU instancing.

Runtime Performance

The foliage system's GPU instancing approach means:

  • Thousands of grass instances render in a single draw call.
  • Wind animation is computed on the GPU (via vertex shader offset).
  • LOD groups reduce triangle count at distance.
  • Dedicated servers skip foliage entirely (no visual rendering).

Worked Code Example: Foliage Manipulation

Region-Based Foliage Clearance

csharp
using SDG.Framework.Foliage;
using UnityEngine;

public class FoliageClearer
{
    /// <summary>
    /// Clears all foliage within a specified radius from a world position,
    /// updating both the FoliageTile data and removing GameObjects.
    /// Useful for construction sites or deforestation mechanics.
    /// </summary>
    public static int ClearFoliageInRadius(Vector3 center, float radius)
    {
        int clearedCount = 0;
        float sqrRadius = radius * radius;

        foreach (FoliageTile tile in FoliageSystem.tiles)
        {
            if (tile == null || tile.instances == null)
                continue;

            FoliageInstance[] instances = tile.instances;
            for (int i = instances.Length - 1; i >= 0; i--)
            {
                FoliageInstance instance = instances[i];
                Vector3 worldPos = tile.localToWorldMatrix.MultiplyPoint3x4(
                    instance.position
                );

                if ((worldPos - center).sqrMagnitude <= sqrRadius)
                {
                    // Remove instance at this index
                    tile.RemoveInstanceAt(i);
                    clearedCount++;
                }
            }
        }

        return clearedCount;
    }
}

Custom Wind Strength Controller

csharp
using SDG.Unturned;
using UnityEngine;

public class WindController : MonoBehaviour
{
    /// <summary>
    /// Overrides the global wind strength for foliage animation,
    /// allowing per-region wind zones (storm cells, indoor areas).
    /// </summary>
    public static void SetWindStrength(float strength)
    {
        // LevelLighting.wind drives the WindZone component
        LevelLighting.wind = Mathf.Clamp01(strength);

        // This propagates to all active WindZone components in the scene
        WindZone[] windZones = GameObject.FindObjectsOfType<WindZone>();
        foreach (WindZone zone in windZones)
        {
            zone.windMain = strength * 2f; // Amplitude multiplier
            zone.windTurbulence = strength * 0.5f;
        }
    }

    /// <summary>
    /// Returns the current wind strength, factoring in time-of-day
    /// modulation from the DayNight cycle.
    /// </summary>
    public static float GetEffectiveWind()
    {
        float baseWind = LevelLighting.wind;
        float timeOfDayFactor = Mathf.Sin(
            LevelLighting.time * Mathf.PI * 2f / LevelLighting.CYCLE
        );

        return baseWind * (0.8f + 0.2f * timeOfDayFactor);
    }
}

Mermaid Diagram: Foliage Baking Pipeline

FeatureFoliageTiles (GPU Instanced)Terrain Detail ObjectsResource TreesObject Props
RenderingGPU instancing, single draw call per tileUnity terrain detail rendererIndividual MeshRenderersIndividual MeshRenderers
Wind animationVertex shader wind offsetLimited detail windGame object-basedNone
LOD support3-level LOD groupsConfigurable density at distancePer-asset LODPer-asset LOD
Collision detectionEnabled (grass push)NoFull colliderFull collider
Baking workflowEditor procedural bakeTerrain painterSpawn systemHand-placement
StorageBinary files per tile in Environment/Foliage/Terrain data embedded in levelPart of LevelGround tree dictObject scene files
Server renderingDisabled on dedicatedDisabledN/A for serversPartially (colliders)
InteractionGrassDisplacement componentNoneHarvestablePickup/use
Draw call cost1 per tile (~1000 instances)1 per detail layer1 per tree1 per object

Failure Modes and Common Mistakes

  1. Foliage tile corruption on partial bake — If the editor crashes or is force-closed during tile baking, the serialized tile file in Environment/Foliage/ may be truncated. On next level load, FoliageSystem attempts to deserialize a partial file, resulting in visible gaps where foliage instances were lost.

  2. Grass displacement not resettingGrassDisplacement pushes grass vertices away from the player during movement. If a player logs out or teleports while standing in grass, the displaced vertices do not reset. The grass remains permanently flattened at that position until the region unloads and reloads.

  3. Wind strength desync across clientsLevelLighting.wind is synchronized by the server, but the WindZone component values are client-side. A custom client with modified wind settings will see different grass animation amplitude than other players. This is cosmetic but can reveal hidden players through grass (lower wind = less sway = easier to spot a stationary player).

  4. Memory leak from tile caching — If FoliageSystem caches tiles without evicting distant ones, a player who flies across the map at high speed can accumulate hundreds of loaded tiles in memory. Each tile stores instance data (positions, rotations, scales, asset references) — roughly 64 bytes per instance × thousands of instances per tile × hundreds of tiles = several hundred MB of RAM.

  5. Resource tree duplication after re-bake — If bakeResources is set to false during a re-bake, handlePreBakeTile does not remove existing resource trees. The bake generates new foliage instances in the same positions as the resource trees, creating visual doubling (two trees at the same spot). This is a common editor workflow error.

How This Field Behaves Differently from the SDG Docs

  • SDG docs describe foliage as "Unity terrain detail." The community resources sometimes conflate the foliage system with Unity's built-in terrain detail system. In the SDK, FoliageTile instances are custom GPU-instanced meshes, not Unity DetailPrototype objects. They bypass the Unity terrain detail renderer entirely.

  • SDG docs claim foliage is "static after bake." The documentation suggests foliage is immutable after the bake step. In the SDK, FoliageSystem.tiles and the FoliageInstance arrays are writable at runtime. Plugins can add, remove, and reposition foliage instances programmatically — though this is uncommon and requires a full GPU buffer rebuild.

  • SDG docs mention "grass settings in terrain config." Some documentation points to Terrain/Details.dat for grass configuration. In the SDK, this file is a legacy DetailObject configuration separate from the FoliageTile system. Modern foliage uses FoliageBakeSettings and asset-driven parameters, not terrain detail configuration.

Performance Considerations

GPU Instancing Benchmarks

  • 10,000 grass instances: 10 draw calls (1 per tile per material) — ~0.1ms GPU time.
  • 100,000 instances: ~100 draw calls — ~1ms GPU time.
  • 1,000,000 instances: ~1000 draw calls — ~10ms GPU time (may exceed 60 FPS budget).

Optimization Strategies

  1. Aggressive LOD culling: Reduce LOD group distances in the foliage asset. Foliage beyond 150 meters rarely needs highest-LOD rendering.
  2. Wind animation culling: Disable wind calculations for foliage beyond a certain distance (wind is imperceptible at 100+ meters).
  3. Tile streaming: Do not load all tiles at once. Stream foliage tiles based on the player's position (load within 3 regions, unload beyond 5).
  4. Collision culling: Disable GrassDisplacement on dedicated servers and for players with graphics settings below Medium.

Memory Budget

Instance CountMemory (64 bytes/instance)Per-Tile OverheadTotal (100 tiles)
500K32 MB250 KB~57 MB
1M64 MB250 KB~89 MB
5M320 MB1 MB~420 MB

Deeper FAQ

Q: Can I selectively bake foliage for only one biome?

Yes. The FoliageBakeSettings structure controls what gets baked. You can specify a material or biome filter during the bake process. The foliage system checks terrain materials (grass, gravel, dirt, sand) and applies density rules per material type. Baking "grass only" on sand tiles produces zero instances.

Q: How does the system handle foliage at the border of two tiles?

Foliage instances belong to exactly one FoliageTile. An instance whose world position falls at the boundary of two tiles is assigned to the tile containing its centroid. There is no explicit boundary blending — the tile grid forms a strict partition of the terrain.

Q: Does the foliage system work on custom maps?

Yes. The FoliageSystem is part of SDG.Framework and is level-agnostic. Custom maps with proper landscape tile setup (matching the coordinate grid expected by the foliage system) can use the bake pipeline. The tiles must align with the landscape coordinate system used by LevelGround.

Q: Can I add collision to foliage for projectile blocking?

Foliage collision is gameplay-only (grass push) and does not participate in bullet/projectile collision. To block projectiles, use ResourceSpawnpoint trees (which have MeshColliders) or object props with proper collision layers.

Q: Why does foliage disappear when I open the inventory?

On some graphics settings, foliage rendering is culled when the player's view is in a UI overlay (inventory, notes, signs). This is a deliberate performance optimization to avoid rendering foliage behind the inventory screen. The behavior depends on the RenderTexture path used for the inventory blur effect.

Cross-References

Document history