Close

Unity 3D – UI Multi-Target Tracker/Radar

In this tutorial, we are going to extend the UI tracker developed in the previous article and change the behaviour in order to implement kind of a multi-target UI tracker/radar/scanner.

The first concept we implement is that any object that “accepts” being tracked makes itself known to the management logic and as soon as a given condition is fulfilled, a tracker UI object will appear around it. In our case, the condition is the distance to the player, but there are many other options.

Making a GameObject registering itself as tracker target is easy. We just need to add a simple behaviour to each object, which in Unity is adding a C# script.

Assets/UITargetTrackerTarget.cs

public class UITargetTrackerTarget : MonoBehaviour {

    public float minimumDistanceToBeTracked = 100f;

    void Awake () {
        UITargetTrackerManager uiTargetTrackerManager = Object.FindObjectOfType<UITargetTrackerManager>();
        if (uiTargetTrackerManager == null)
            Debug.LogError("UITargetTrackerManager not found in scene. Can't register myself as tracker target, " + this.name);
        else
            uiTargetTrackerManager.RegisterAsTarget(this);
    }
} 

The script looks for the tracker management component and adds itself to the registry. Using this “subscription” pattern, the management object does not need know all possible targets before starting the scene.

Before discussing the tracker management logic, lets re-examine the tracker UI object class, which is taken from the previous tutorial.

Assets/UITargetTracker.cs

public class UITargetTracker : MonoBehaviour {
    
    // Defines how much % the tracker rectangle is enlarged compared to the 3D screen object bounds
    public float rectanglePaddingMultiplier=0.1f;

    // The object to track
    public GameObject objectToTrack;

    /*
     * Called every frame.
     * As long as the instance exist, we supposed here that the display must happen. 
     * The various conditions on visibility et al are calculated in the manager.
     */
    private void Update() {
        
        if (!objectToTrack)
            return; 
        
        // New tracker rectangle size
        Rect visualRect = TargetObjectBoundsToScreenSpace(objectToTrack.GetComponentInChildren<Renderer>());

        // Parent UI component position on the target object visual 2D UI position (anchor is 0,0)
        RectTransform rt = GetComponent<RectTransform>();
        rt.position = new Vector2(visualRect.xMax, visualRect.yMin);

        // Adjust all child elements. The tracker rectangle image to the size of the visual 2D bounds and
        // the other object statically on the side, attacked to the rectangle
        foreach (Transform child in transform) {
            
            // Make all child elements visible
            // The parent UI components itself is always active, otherwise it does not receive update events
            child.gameObject.SetActive(true);

            float xPadding = visualRect.width * rectanglePaddingMultiplier;
            float yPadding = visualRect.height * rectanglePaddingMultiplier;

            if (child.name == "RectangleImage")    {
                // Resize the tracker sprite rectangle
                rt = child.GetComponent<RectTransform>();
                rt.position = new Vector2(visualRect.xMin - xPadding, visualRect.yMin - yPadding);
                rt.sizeDelta = new Vector2(visualRect.width + xPadding * 2, visualRect.height + yPadding * 2);
            }
            else {
                // Reposition the other objects (texts and sprites) beside the tracker rectangle
                rt = child.GetComponent<RectTransform>();
                rt.position = new Vector2(visualRect.xMin + visualRect.width + xPadding, rt.position.y);
            }
        }

    }
        
        
    /*
     * Project the 3D scene object towards the 2D UI canvas, then take the most left, the most right, the most up and the most 
     * down 2D points of all to determine the outer visual bounds on the UI overlay of the object. This is the size of the 
     * rectangle "visually" surrounding the object when drawn on the UI plane.
     */
    private Rect TargetObjectBoundsToScreenSpace(Renderer r) {
        
        // Object visual rectangle in world space
        Bounds b = r.bounds;
        
        // Calculate the screen space rectangle surrounding the 3D object
        Camera c = Camera.main;
        Rect rect = Rect.zero;
        Vector3 screenSpacePoint;

        screenSpacePoint = c.WorldToScreenPoint(new Vector3(b.center.x + b.extents.x, b.center.y + b.extents.y, b.center.z + b.extents.z));
        AdjustRect(ref rect, screenSpacePoint, true);
        screenSpacePoint = c.WorldToScreenPoint(new Vector3(b.center.x + b.extents.x, b.center.y + b.extents.y, b.center.z - b.extents.z));
        AdjustRect(ref rect, screenSpacePoint);
        screenSpacePoint = c.WorldToScreenPoint(new Vector3(b.center.x + b.extents.x, b.center.y - b.extents.y, b.center.z + b.extents.z));
        AdjustRect(ref rect, screenSpacePoint);
        screenSpacePoint = c.WorldToScreenPoint(new Vector3(b.center.x + b.extents.x, b.center.y - b.extents.y, b.center.z - b.extents.z));
        AdjustRect(ref rect, screenSpacePoint);
        screenSpacePoint = c.WorldToScreenPoint(new Vector3(b.center.x - b.extents.x, b.center.y + b.extents.y, b.center.z + b.extents.z));
        AdjustRect(ref rect, screenSpacePoint);
        screenSpacePoint = c.WorldToScreenPoint(new Vector3(b.center.x - b.extents.x, b.center.y + b.extents.y, b.center.z - b.extents.z));
        AdjustRect(ref rect, screenSpacePoint);
        screenSpacePoint = c.WorldToScreenPoint(new Vector3(b.center.x - b.extents.x, b.center.y - b.extents.y, b.center.z + b.extents.z));
        AdjustRect(ref rect, screenSpacePoint);
        screenSpacePoint = c.WorldToScreenPoint(new Vector3(b.center.x - b.extents.x, b.center.y - b.extents.y, b.center.z - b.extents.z));
        AdjustRect(ref rect, screenSpacePoint);

        return rect;
    }
    
    private void AdjustRect(ref Rect rect,Vector3 pnt,bool firstCall=false) {
        if (firstCall) {
            rect.xMin=pnt.x;
            rect.yMin = pnt.y;
            rect.xMax = pnt.x;
            rect.yMax = pnt.y;
        }
        else {
            rect.xMin = Mathf.Min(rect.xMin, pnt.x);
            rect.yMin = Mathf.Min(rect.yMin, pnt.y);
            rect.xMax = Mathf.Max(rect.xMax, pnt.x);
            rect.yMax = Mathf.Max(rect.yMax, pnt.y);
        }
    }

    /*
     * Resets the instance, generally invoked when put back in pool or when taken out of pool.
     */
    public void ResetState(GameObject trackerTarget=null) {
        
        // Set anchor and pivot points of main and child objects to overwrite bad configuration the user made in the inspector.
        // The Y size of sub-elements other than the rectangle is taken from the inspector and not modified.
        RectTransform rt = this.GetComponent<RectTransform>();
        rt.position = Vector2.zero; // left bottom corner
        rt.anchorMax = Vector2.zero;
        rt.anchorMin = Vector2.zero;
        rt.pivot = Vector2.zero;

        foreach (Transform child in transform) {
            
            child.gameObject.SetActive(false);

            rt = child.GetComponent<RectTransform>();
            rt.anchorMax = Vector2.zero; // left bottom 
            rt.anchorMin = Vector2.zero;
            rt.pivot = Vector2.zero;
        }

        objectToTrack = trackerTarget;
    }
}

There are just small changes here. First, the object-to-be-tracked is now passed as parameter, and second, the GameObject can now exist without having a target assigned. The latter is required for object pooling.

The real magic happens in the management class.

Assets/UITargetTracker.cs

/*
 * Manages the tracker UI object pool, the registry of objects allowing themselves
 * to be targeted and everything around tracking.
 */
public class UITargetTrackerManager : MonoBehaviour
{

    private class TargetDescriptor
    {
        public UITargetTrackerTarget target;
        public GameObject tracker; // ...or null if no tracker is attached. See UpdateTargetStatesTicker()
    }

    // To be assigned in the inspector
    public GameObject UITargetTrackerPrefab;

    // Static singleton instance reference
    public static UITargetTrackerManager Instance { get; private set; }

    // List of ALL registered target objects, with tracker attached or not.
    private List<TargetDescriptor> targetObjectRegistry = new List<TargetDescriptor>();

    // Tracker UI instances pool
    private Stack<GameObject> trackerInstancePool = new Stack<GameObject>(20);

    /*
     * 
     */
    private void Awake() {
        // Handle single-tone aspect
        if (UITargetTrackerManager.Instance != null && UITargetTrackerManager.Instance != this)
            throw new Exception("This script can only exist once in the scene.");
        UITargetTrackerManager.Instance = this;
    }

    /*
     * 
     */
    private void Start() {
        // Calculate xxx of potential targets every N seconds.
        // As the targets are supposed to be far away, there is no need to do this every frame.
        // The required frequency depends on the speed of the observer and the require
        // reactivity of the trackers. Call to the ticker method can also by placed in Update().
        InvokeRepeating("UpdateTargetStatesTicker", 0, 0.5f);
    }

    /*
     * Goes of all registered targets and verifies if they are elligitable for having a target tracker attached.
     */
    private void UpdateTargetStatesTicker() {
        
        // All potential target objects we know of
        foreach (TargetDescriptor descr in targetObjectRegistry) {

            bool needTracker = false;

            // Yes, is it closer than the minimum distance required to be tracked?
            if (Vector3.Distance(descr.target.transform.position, Camera.main.transform.position) < descr.target.minimumDistanceToBeTracked) {
                // Yes, is it on front of camera?
                Vector3 targetScreenPoint = Camera.main.WorldToViewportPoint(descr.target.transform.position);
                if (targetScreenPoint.z > 0 && targetScreenPoint.x > 0 && targetScreenPoint.x < 1 && targetScreenPoint.y > 0 && targetScreenPoint.y < 1)
                    // Yes, add a tracker, otherwhise remove
                    needTracker = true;
            }

            if (needTracker && descr.tracker == null) {
                descr.tracker = AllocateInstance(descr.target.gameObject); // from pool
            }
            else if (!needTracker && descr.tracker != null) {
                ReleaseInstance(descr.tracker); // back to pool
                descr.tracker = null;
            }
        }
    }

    /*
     * Takes a tracker instance from the pool. If the pool is empty, a new instance is created, which will then later
     * be put back into the pool. By this, the pool builds up to the maximum amount of trackers simultaneously displayed at one moment.
     */
    private GameObject AllocateInstance(GameObject target) {

        GameObject go;
        if (trackerInstancePool.Count > 0)
            go = trackerInstancePool.Pop();
        else
            go = Instantiate(UITargetTrackerPrefab, this.gameObject.transform);

        go.GetComponent<UITargetTracker>().ResetState(target);

        return go;
    }

    /*
     * Reset a tracker instance and places it back in the pool.
     */
    private void ReleaseInstance(GameObject tracker) {
        tracker.GetComponent<UITargetTracker>().ResetState();
        trackerInstancePool.Push(tracker);
    }

    /*
     * Removes all objects from the pool that built-up over time. The objects will then be garbage collected.
     * There is no need to call the method if the pool remains in a reasonable size.
     */
    private void ClearPool() {
        trackerInstancePool.Clear();
    }


    /*
     * Invoked by a game object to mark itself as target. 
     * By doing this, as soon as it satisfies the "show target tracker" condition,
     * the tracker is displayed in the UI canvas.
     * The simplest way to have objects invoking this method is be assigning the 
     * "UITargetTrackerTarget" behaviour, ie. adding the script as component.
     */
    public void RegisterAsTarget(UITargetTrackerTarget target) {
        TargetDescriptor td = new TargetDescriptor();
        td.target = target;
        td.tracker = null; // tracker gets assigned when conditions are met, see UpdateTargetStates()

        targetObjectRegistry.Add(td);
    }

    public void UnregisterAsTarget(UITargetTrackerTarget target) {
        foreach (TargetDescriptor descr in targetObjectRegistry)
            if (descr.target == target) {
                targetObjectRegistry.Remove(descr);
                return;
            }
    }
} 

This script is added to the tracker list parent UI object and requires the tracker UI object prefab to be set in the inspector. All new trackers instances are added below this UI GameObject to avoid “polluting” the object tree with UI tracker clones.

The first observation is that the object is a single-tone. This signifies that only one instance exists per scene.

It provides a public method allowing everybody to register as target. The method is invoked by each “client” in the “UITargetTrackerTarget()” method.

Moreover, the class contains a Stack of GameObjects, which will be filled with UI tracker instances on the fly. This stack is the object pool that allows to quickly re-use a currently unused tracker UI object, avoiding to perform a slow instantiation each time. In this case, the pool is not pre-filled, but new objects are added when the pool is empty and an instance is needed. Over time, the pool will fill-up with the number of instances equal to the maximum number of trackers displayed in one single moment. This behaviour can be adapted by introducing an upper instance limit, for instance.

The method “UpdateTargetStatesTicker()” does the hard work by iterating over all objects registered as potential targets and by testing if the condition for adding a tracker UI is fulfilled. In our case, the method tests if the object is close enough and if it is located in the for us visible part of the scene. The object is either pulled from the pool or created when required, and pushed back onto the pool if not used anymore.

In this example, the method is called every ½ second. This is done for performance reason as this calculation might not needed to be performed on every frame. When flying over a landscape having targets far away, the half second of delay is not dramatic (it might even represent the “scanning delay”). In small scenes that hold a lot of objects, the frequency can be incremented or the method can be invoked in “Update()” to obtain the highest reactivity.

Et voilà, that’s all there is…

Source code: https://github.com/imifos/unity3d-workspace/

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

By continuing to use the site, you agree to the use of cookies. more information

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close