Introduction

In Sunrise Village you can explore a beautiful world full of lush vegetation. The ground and many objects are covered with grass, flowers, ferns and water plants. We call this kind of ground foliage “MicroVegetation“. MicroVegetation is not just static: When you chop down a tree, grass regrows at the new empty spot and when you build a house the vegetation is removed from the building’s footprint area. We created a custom system to render and edit MicroVegetation system.

This is a series of three articles describing why and how we did this. The first article was about rendering vegetation, the second part explains how we can modify terrain at runtime. And this third post will deal with painting vegetation in the Unity Editor.

A custom editor for MicrovegetationRenderer

Painting vegetation in the Unity editor is handled by a custom inspector for MicroVegetationRenderer. It has two modes: A settings mode to specify basic parameters like the splat map and vegetation set and a paint mode. This is inspired by Unity Terrain editor to make micro vegetation painting easily accessible. In paint mode users can select a vegetation preset from a list and paint vegetation in the scene view using the mouse and keyboard shortcuts. So what MicroVegetationEditor basically does is to modify the MicroVegetationSplatMap container assigned to the MicroVegetationRenderer based on user input and updating the renderer.

Paint Mode
Settings Mode

SceneView updates

MicroVegetationRendererEditor needs to listen to events from the Unity editor. The natural place would be to implement OnSceneGUI(). Unfortunately OnSceneGUI() is only called if the user has Gizmos turned on – which is not always the case! So instead we register to SceneView.duringSceneGui which will be called on every scene gui update even if the user has gizmos turned off. On the callback we react to the events if we are in paint mode. Please note that we return immediately if the alt key is down. This is required to allow the user to modify the view camera while painting.

Gizmos toggle
private void OnEnable()
{
    State = JsonEditorPrefs.Read<EditorState>(PrefsKey);
    Undo.undoRedoPerformed += UndoRedoPerformed;
    SceneView.duringSceneGui += OnSceneGUICallback;
 
    if (_activeInspectorId == 0)
    {
        _activeInspectorId = GetInstanceID();
    }
}
 
...
 
private void OnSceneGUICallback(SceneView sceneView)
{
    if (!Renderer.HasSplatMapAndCollider || !Renderer.HasValidSplatMapSize || !IsActiveInspector)
    {
        return;
    }
 
    if (_paintMode == PaintModes.Paint)
    {
        HandlePaintMode();
    }
 
    // Force update when the user moved the collider
    if (Renderer.Collider.transform.hasChanged)
    {
        UpdateMicroVegetation();
        Renderer.Collider.transform.hasChanged = false;
    }
}
 
private void HandlePaintMode()
{
    Event e = Event.current;
 
    if (e.alt)
    {
        return;
    }
 
    int id = GUIUtility.GetControlID(EditorHash, FocusType.Passive);
 
    switch (e.GetTypeForControl(id))
    {
        case EventType.ValidateCommand:
            HandleValidateCommand(e);
            break;
        case EventType.ExecuteCommand:
            HandleExecuteCommand(e);
            break;
        case EventType.Layout:
            HandleUtility.AddDefaultControl(id);
            break;
        case EventType.MouseMove:
            HandleUtility.Repaint();
            break;
        case EventType.MouseDown:
        case EventType.MouseDrag:
            HandleMouseDown(e);
            break;
        case EventType.ScrollWheel:
            HandleScrollWheel(e);
            break;
        case EventType.KeyDown:
            HandleKeyDown(e);
            break;
        case EventType.KeyUp:
            HandleKeyUp(e);
            break;
        case EventType.Repaint:
            UpdatePreviewBrush();
            break;
    }
 
}

Painting with the mouse

When the user clicks or drags the mouse HandleMouseDown() is called. When the user started a new paint stroke an undo step is recorded with the splat map container. Then we cast a ray onto the target surface of MicroVegetationRenderer which is either the terrain or a mesh. The ray hit position is converted into a normalized 2d position on the target surface and the Paint() method is called which updates the splat map pixel by pixel based on the current brush size.

private void Paint(Vector2 center)
{
    int xCenter = Mathf.FloorToInt(center.x * SplatMap.Width);
    int yCenter = Mathf.FloorToInt(center.y * SplatMap.Height);
 
    int size = Mathf.RoundToInt(State.BrushSize * (SplatMap.Width / ColliderBounds.size.x));
    int intRadius = size / 2;
    int intFraction = size % 2;
 
    int xMin = Mathf.Clamp(xCenter - intRadius, 0, SplatMap.Width - 1);
    int yMin = Mathf.Clamp(yCenter - intRadius, 0, SplatMap.Height - 1);
 
    int xMax = Mathf.Clamp(xCenter + intRadius + intFraction, 0, SplatMap.Width);
    int yMax = Mathf.Clamp(yCenter + intRadius + intFraction, 0, SplatMap.Height);
 
    int width = xMax - xMin;
    int height = yMax - yMin;
 
    var brushCenter = new Vector2Int(width / 2, height / 2);
 
    if (_spaceDown)
    {
        byte currentLayer = SplatMap.Get(xCenter, yCenter);
        if (currentLayer != 0)
        {
            State.SelectedLayer = currentLayer;
        }
        return;
    }
 
 
    for (var y = 0; y < height; y++)
    {
        for (var x = 0; x < width; x++)
        {
            if (Vector2Int.Distance(new Vector2Int(x, y), brushCenter) > intRadius)
            {
                continue;
            }
 
            var xSplatMap = x + xMin;
            var ySplatMap = y + yMin;
            bool erase = Event.current.control;
            bool eraseCurrentOnly = erase && Event.current.shift;
            bool paintOnEmptyOnly = !erase && Event.current.shift;
            byte currentLayer = SplatMap.Get(xSplatMap, ySplatMap);
 
            if (eraseCurrentOnly && currentLayer != State.SelectedLayer)
            {
                continue;
            }
 
            if (paintOnEmptyOnly && currentLayer != 0)
            {
                continue;
            }
 
            SplatMap.Set(xSplatMap, ySplatMap, erase ? (byte)0 : State.SelectedLayer);
        }
    }
 
    var rect = new Rect(
        ColliderBounds.min.x + center.x * ColliderBounds.size.x - State.BrushSize / 2f,
        ColliderBounds.min.z + center.y * ColliderBounds.size.z - State.BrushSize / 2f,
        State.BrushSize,
        State.BrushSize
    );
    UpdateMicroVegetation(rect);
}
 
private void UpdateMicroVegetation(Rect rect)
{
    if (!Renderer.HasSplatMapAndCollider)
    {
        return;
    }
 
    Renderer.RequestUpdateOfPatchesInArea(rect);
    EditorUtility.SetDirty(Renderer.SplatMap);
    EditorApplication.QueuePlayerLoopUpdate();
 
    Renderer.OnDataChanged?.Invoke();
}

At the end of Paint() we ask the renderer to update the modified area. Also the splat map container is marked dirty so Unity will save it. Lastly QueuePlayerLoopUpdate() is called to force Unity to repaint the scene view.

Drawing the preview brush

When the paint mode is active we draw a preview brush in every frame. This is done by casting a ray and drawing a wire disc:

Preview Brush
private void UpdatePreviewBrush()
{
    if (_paintMode == PaintModes.Settings || !Renderer.HasSplatMapAndCollider || !Renderer.HasValidSplatMapSize)
    {
        return;
    }
 
    if (Raycast(out Vector3 pos, out Vector3 normal))
    {
        Handles.DrawWireDisc(pos, normal, State.BrushSize / 2f);
    }
}

Wrap up

After we already handled rendering and updating MicroVegetation in the first two parts of this series the only part that’s missing to paint vegetation in the editor is a custom inspector that can update the splat map based on the mouse input. This concludes our series about how we implemented MicroVegetation in Sunrise Village.

We hope you enjoyed our journey and learned something for you own projects. Thank you for reading!

InnoGames is hiring! Check out open positions and join our awesome international team in Hamburg at the certified Great Place to Work®.

By Andreas Hackel

Technical Artist @ InnoGames