Rectangle Box Selection like in RTS games - In 3D world with perspective camera

Hi,
I wanted to accomplish the following:
Rectangle Box Selection like in RTS games - In 3D world with perspective camera

And as I was struggling through the internet the past days, either understanding the basics of camera, perspective, RayCasts etc.etc. and all the good stuff I stumble through a lot of partial help, but no final solution ever came across to how to put it all together, but I finally managed to completely write my own solution to this which I found to be pretty handy.

I leave it to you how to take input into “InputReceiverManager” or substitute the related parts of the code according to your own Input receiving methods. If you are looking for such a solution below, at this point you will be already able to do the input stuff (I use the new input system e.g.).

Also this example doesn’t make use of the generated shape here in the showcase yet.
Just go from there with the generated shape, because I also assume at this point you are able to utilize a simple collision detection according to the generated shape from my solution.

Its really all about getting the selection out into the world!

Please find a visualization further below.
It also works with any camera angle and rotation and updates your drag while moving around :slight_smile:

(btw, the solution below also includes a single click detection on any object of the given layer, just as a bonus, as it was in my script anyways :-D)

using System;
using UnityEngine;

/// <summary>
/// Draws a selection rectangle on left mouse button down & dragging.
/// Also generates a 3D space selection mesh according to your selection.
///
/// You only need an InputReceiverManager that basically tracks
/// - if the left mouse button is currently down and saves it in "LeftMouseButtonDown"
/// - saves the initial click position when mouse button was clicked  and saves it in "InitialMousePositionOnLeftClick"
/// - updates the current mouse position and saves it in "CurrentMousePosition"
///
/// </summary>
public class InputHandlingManager : MonoBehaviour
{
    public static InputHandlingManager Instance;

    #region Variables Single Click
    //the layer which the mouseclick should account to
    public LayerMask MouseClickLayer;

    //for single click
    private Ray _mouseClickRay;
    #endregion

    #region Variables Selection Rectangle
    public Color SelectionRectangleFillerColor;
    public Color SelectionRectangleBorderColor;
    public int SelectionRectangleBorderThickness = 4;

    public bool DrawSelectionRays = false;
    public bool DrawSelectionGOFaces = false;
    public bool KeepDrawnSelectionGO = false;

    private Texture2D _selectionRectangleFiller;
    private Texture2D _selectionRectangleBorder;
    private bool _drawSelectionRectangle;
    private float _x1, _x2, _y1, _y2;

    private Vector3 _mouseSelectionRectangleCenter;
    private float _redrawTimer = 0f;
    private float _selectionProjectionDistance = 200f;
    private GameObject _selectionMeshGO;

    #endregion


    private void Awake()
    {
        if (Instance)
            throw new Exception("InputHandlingManager already exists as: '" + Instance.gameObject.name + "' under parent: '" + Instance.transform.parent.name + "'");
        Instance = this;
    }

    void Start()
    {
        InitManager();
    }

    private void InitManager()
    {
        generateTexturesForSelectionRectangle();
    }

    void Update()
    {
        //simple left click
        if (InputReceiverManager.Instance.LeftMouseFirstInitialTap)
            performWorldClick();

        //calculates if we are dragging a selection rectangle
        if (InputReceiverManager.Instance.LeftMouseButtonDown && !Mathf.Approximately(Vector2.Distance(InputReceiverManager.Instance.CurrentMousePosition,
                                                                                                       InputReceiverManager.Instance.InitialMousePositionOnLeftClick), 0f))
        {
            calculateSelectionRectanglePositions();
            _drawSelectionRectangle = true;

            try
            { multiSelectBuildings(); }
            catch (Exception e)
            { Debug.Log("Just Ingore this, niche issue! Generating the selection sesh sameObject failed due to this stupid CONVEX collider calculation issue ~,~ " + e.Message); }
        }
        else if (!InputReceiverManager.Instance.LeftMouseButtonDown && _drawSelectionRectangle)
        {
            _drawSelectionRectangle = false;
            if (_selectionMeshGO != null && !KeepDrawnSelectionGO) DestroyImmediate(_selectionMeshGO);
        }
    }
    private void OnGUI()
    {
        if (_drawSelectionRectangle)
        {
            drawSelectionRectangle();
        }
    }

    #region Methods for Selection Rectangle
    private void generateTexturesForSelectionRectangle()
    {
        //define inner color for the selection rectangle
        _selectionRectangleFiller = new Texture2D(1, 1);
        _selectionRectangleFiller.SetPixel(0, 0, SelectionRectangleFillerColor);
        _selectionRectangleFiller.Apply();

        //define border color for the selection rectangle
        _selectionRectangleBorder = new Texture2D(1, 1);
        _selectionRectangleBorder.SetPixel(0, 0, SelectionRectangleBorderColor);
        _selectionRectangleBorder.Apply();
    }

    private void multiSelectBuildings()
    {
        //redraw selection only based on this interval to reduce overhead
        _redrawTimer += Time.deltaTime;
        if (_redrawTimer > 0.05f)
        {
            _redrawTimer = 0f;

            //take the corners of the selection rectangle as calculated in calculateSelectionRectanglePositions()
            Vector3[] corners = {
                                new Vector3(_x1, _y2), //top left
                                new Vector3(_x2, _y2), //top right
                                new Vector3(_x1, _y1), //bottom left
                                new Vector3(_x2, _y1)  //bottom right
                            };

            //fire 4 Rays from camera through each of the 4 corners of the selection
            Ray topLeftRay = CameraManager.MainCamera.ScreenPointToRay(corners[0]);
            Ray topRightRay = CameraManager.MainCamera.ScreenPointToRay(corners[1]);
            Ray bottomLeftRay = CameraManager.MainCamera.ScreenPointToRay(corners[2]);
            Ray bottomRightRay = CameraManager.MainCamera.ScreenPointToRay(corners[3]);

            // set flag if you want to draw the rays for the selected area
            if (DrawSelectionRays)
            {
                Debug.DrawLine(topLeftRay.origin, topLeftRay.GetPoint(200f), Color.red);
                Debug.DrawLine(topRightRay.origin, topRightRay.GetPoint(200f), Color.red);
                Debug.DrawLine(bottomLeftRay.origin, bottomLeftRay.GetPoint(200f), Color.red);
                Debug.DrawLine(bottomRightRay.origin, bottomRightRay.GetPoint(200f), Color.red);
            }


            //save 4 projected points at distance along the fired Rays
            Vector3[] projectionEnds = {
                                        topLeftRay.GetPoint(_selectionProjectionDistance), //top left
                                        topRightRay.GetPoint(_selectionProjectionDistance), //top right
                                        bottomLeftRay.GetPoint(_selectionProjectionDistance), //bottom left
                                        bottomRightRay.GetPoint(_selectionProjectionDistance) //bottom right                                      
                                        };

            //generate the selection object mesh according to the projected points in the distance
            Mesh generatedSelectionMesh = generateSelectionMesh(projectionEnds);

            //if a selection object is already there, destroy it
            if (_selectionMeshGO != null) DestroyImmediate(_selectionMeshGO);

            //finally generate the 3D object in space from the previous generated mesh details
            _selectionMeshGO = generateSelection3DObject(generatedSelectionMesh);
        }
    }

    private GameObject generateSelection3DObject(Mesh selectionMesh)
    {
        //create new object for the mesh
        GameObject meshGO = new GameObject("selectionMeshGO");
        MeshRenderer meshRenderer = meshGO.AddComponent<MeshRenderer>();

        //add the collider for later inbound collision detection
        MeshCollider meshCollider = meshGO.AddComponent<MeshCollider>();
        meshCollider.sharedMesh = selectionMesh;
        meshCollider.convex = true;
        meshCollider.isTrigger = true;

        // make the faces visible of the selectionMeshGO
        if (DrawSelectionGOFaces)
        {
            MeshFilter meshFilter = meshGO.AddComponent<MeshFilter>();
            meshFilter.mesh = selectionMesh;
            Material selectionMeshMaterial = new Material(Shader.Find("Universal Render Pipeline/Lit"));
            selectionMeshMaterial.SetColor("_BaseColor", Color.blue);
            meshGO.GetComponent<Renderer>().material = selectionMeshMaterial;
        }

        return meshGO;
    }

    //generates the selection object mesh according to the projected points in the distance
    private Mesh generateSelectionMesh(Vector3[] projectionEndPoints)
    {
        Vector3[] projectionEndPointsAll = new Vector3[5];

        projectionEndPointsAll[0] = CameraManager.MainCamera.transform.position;
        Array.Copy(projectionEndPoints, 0, projectionEndPointsAll, 1, 4);

        Mesh selectionMesh = new Mesh();
       
        selectionMesh.Clear();
        selectionMesh.vertices = projectionEndPointsAll;
        selectionMesh.triangles = new int[] {
                                            0, 3, 1,
                                            0, 1, 2,
                                            0, 2, 4,
                                            0, 4, 3,
                                            1, 3, 2,
                                            2, 3, 4
                                            };

        return selectionMesh;
    }

    //calculates the positions according where the mouse started when start draggin and where the mouse is now live
    private void calculateSelectionRectanglePositions()
    {
        Vector2 pos1 = InputReceiverManager.Instance.InitialMousePositionOnLeftClick;
        Vector2 pos2 = InputReceiverManager.Instance.CurrentMousePosition;
       
        if (pos1.x < pos2.x) //initial mouse position is left from the moving mouse position
        {
            _x1 = pos1.x;
            _x2 = pos2.x;
        }
        else //initial mouse poistion right from moving mouse position
        {
            _x1 = pos2.x;
            _x2 = pos1.x;
        }

        if (pos1.y < pos2.y) //initial mouse position is below the moving mouse position
        {
            _y1 = pos1.y;
            _y2 = pos2.y;
        }
        else //initial mouse position is above the moving mouse position
        {
            _y1 = pos2.y;
            _y2 = pos1.y;
        }

        _mouseSelectionRectangleCenter = new Vector3(Mathf.Floor((_x1 + _x2)/2), Mathf.Floor((_y1 + _y2) / 2),0f);
    }

    //draws a rectangle into screenspace according to the players inputs of dragging a selection box
    private void drawSelectionRectangle()
    {
        //filler
        GUI.DrawTexture(new Rect(_x1, Screen.height - _y1, _x2 - _x1, _y1 - _y2), _selectionRectangleFiller, ScaleMode.StretchToFill);

        //top line
        GUI.DrawTexture(new Rect(_x1, Screen.height - _y1, _x2 - _x1, -SelectionRectangleBorderThickness), _selectionRectangleBorder, ScaleMode.StretchToFill);

        //bottom line
        GUI.DrawTexture(new Rect(_x1, Screen.height - _y2, _x2 - _x1, SelectionRectangleBorderThickness), _selectionRectangleBorder, ScaleMode.StretchToFill);

        //left line
        GUI.DrawTexture(new Rect(_x1, Screen.height - _y1, SelectionRectangleBorderThickness, _y1 - _y2), _selectionRectangleBorder, ScaleMode.StretchToFill);

        //right line
        GUI.DrawTexture(new Rect(_x2, Screen.height - _y1, -SelectionRectangleBorderThickness, _y1 - _y2), _selectionRectangleBorder, ScaleMode.StretchToFill);
    }

    #endregion

    #region Method for Single Click
    //performs a single click through the world and pools it to another manager to do something
    private void performWorldClick()
    {
        _mouseClickRay = CameraManager.MainCamera.ScreenPointToRay(InputReceiverManager.Instance.CurrentMousePosition);


        if (Physics.Raycast(_mouseClickRay, out RaycastHit hit))
        {
            MousePointer.transform.position = hit.point; //3D cube mouse pointer for testing

            switch (LayerMask.LayerToName(hit.transform.gameObject.layer))
            {
                case "Buildings":
                    BuildingsInteractionManager.BuildingClicked(hit.transform);
                    break;
            }
        }

        InputReceiverManager.Instance.LeftMouseFirstInitialTap = false;
    }
    #endregion
}

FPS of the GIF is bad, but otherwise I couldnt show as much:
Showcase 9162338--1274762--SelectionBox_Showcase.gif
Feel free to ask any questions.
In case people don’t know how to achive the prerequisites of “InputReceiverManager” and afterwards how to utilize the generated shape to detect for collision and therefor finally select the objects within, let me know. I will go the extra mile if needed, but for now I wanted to have it compressed to what was the biggest challenge for me.

Have fun with it :slight_smile:

2 Likes

:sunglasses:

By InputReceiverManager do you mean the EventSystem?

No, he meant a custom POCO (Plain Old C# Object) singleton. Yes, it was likely implemented via some of the existing Unity’s input handlers.

In the solution, OP had to defer mouse handling to an external object to decouple that from the core logic. You need to re-implement this in the way as described in the top code comment.

Too bad OP didn’t just declare an interface, but his InputReceiverManager is not that complicated to redo given the above guidelines.