Building an XR Application in Unity with MRTK [Part 5] — Placing targets on the environment

Troy Ferrell
10 min readMar 25, 2020

Now that we have created two types of targets in the previous article, we need to spawn and place instances of these into the environment for the player to score against. Since the environment will be built by the spatial mapping output of the HoloLens platform, every scene will be different based on where the user is and what the user has scanned thus far. Therefore, we cannot use pre-determined spawn points. We also want to ensure we have a variety of locations utilized from the environment. Finally, we want the targets to be somewhat equally spaced out. The Targets should not be overlapping or abutting in the same region.

Getting Started

First, we need to create the prefab that will host our TargetManager instance in scene. Furthermore, we need to create the ObjectPool component that will allocate and manage the pool of Target instances at runtime. These will be used by our TargetManager script to request and place Target instances into the environment.

  1. Create a new empty GameObject in the root scene named TargetManager.
  2. Drag this new GameObject into the Prefabs folder to create an original prefab and open the prefab view.
  3. Create two ObjectPool components onto the TargetManager prefab. NOTE: This component can be found in the full project source.
  4. Drag the MarkerTarget prefab created in the previous article onto one of the ObjectPool component’s Prefab property. Set the ObjectPoolSize property to 10 or other desired value.
  5. Drag the RingTarget prefab created in the previous article onto the other ObjectPool component’s Prefab property. Set the ObjectPoolSize property to 10 or other desired value.
  6. Create a new script TargetManager component and add it to the prefab.

Writing the TargetManager script

The primary purpose of the TargetManager is to randomly place either Bullseye or Ring targets into the scene. For this implementation, we will use random timers and random raycasts from the camera to determine suitable positions to place our targets.

After some random amount of time has elapsed, our TargetManager will attempt to create a bullseye or ring target. First, we will see if there is an instance currently inactive in our ObjectPool that we can request. Next, we will calculate a random direction vector in a 180 degree space aligned with the view of our camera. We will perform a raycast operation based on the type of Target we are trying to place. If we hit a point on the spatial mapping mesh, we will attempt to place a Target along this raycast if it is a valid placement point. The point is valid if there are no other Targets in the near vicinity of this point.

Define the class and Initialize

The TargetManager will be a sole instance in the scene and thus act as a Singleton. The Singleton model is a software engineering pattern where by instantiation of a class is restricted to a single instance. There is a public implementation of Singleton for Unity we will use which simplifies defining the class to simply extend from this base class.

Now that we have defined the class, we need to define some serialized properties that can be configured in editor. We need references to the two ObjectPool components on the same GameObject: one for bullseye markers and one for rings.

Next, we need a LayerMask property that will define what are the layers are valid for our raycast operations. MRTK by default places all spatial mapping meshes on the Spatial Awareness layer which is layer 31.

Every cycle the TargetManager class will select a random timer limit for both bullseye targets and ring targets individually. Once enough time has elapsed for a timer, we will try to spawn another Target of that type and calculate a new random timer limit while resetting our current timer to 0. The MinSpawnThreshold and MaxSpawnThreshold properties will define the valid range for our random timer limits in seconds. Thus, if our range is 3 to 6 seconds, then each cycle we will calculate a random timer limit in this range such as 4.8 seconds.

The MinDistanceSpace defines the minimum amount of space we want to maintain between Targets so they do not clump together.

The SphereCastRadius is exactly what it sounds like. The radius of the spherecast operation we will perform for placing the rings targets. This should be modified based on the size of the Ring mesh used in the game.

On initialize (i.e Awake()), we validate that our MinSpawnThreshold is indeed less than MaxSpawnThreshold and initialize random timer limits for each target type. Furthermore, we convert our MinDistanceSpace into it’s squared value.

NOTE: In a later section, we will perform a simple distance comparison operation on every active Target in the scene to confirm if we can place a new Target at some position. Distance operations requires a square root operation which is quite expensive on hardware, especially compared to multiplication. Since we do not require the exact distance value and only need to compare two distance values, we can simply compare their sqrMagnitude values.

Update Loop

Every update we simply need to increase our timers, one for bulls-eye marker targets and one for ring targets, by the amount of time that has elapsed since our last update (i.e Time.deltaTime). Then for each timer, we need to compare if we have hit our timer limit or not (i.e enough time has elapsed). If enough time has elapsed, then we

  1. Reset our timer variable
  2. Calculate a new timer limit based on our threshold range properties
  3. Attempt to PlaceMarker() or PlaceRing()

Place Targets Logic

Now, we have reached the core logic that this TargetManager script is responsible for: the placement of Targets. The logic for placing a ring is slightly different than placing a bulls-eye marker. Before getting into the PlaceMaker() and PlaceRing() functions, let’s dive a bit into the helper functions.

Generating a random direction:

In order to generate a random direction to perform some raycast operation, we have the Vector3 GenerateRandomDirection(Transform transform) helper function. This static function takes in a transform, which in our case will be our main camera, and calculates a random vector in a 180 degree view relative to the transform’s forward.

We effectively want to isolate the forward component and then add a random offset along the right axis of the transform and the up axis of the world. IMPROTANT: We are using the up axis of the world instead of the provided transform’s up axis. This way any ray casts will still be relatively in front of the user’s FOV instead of behind them in the case they are looking down at the ground or up at the ceiling.

Thus, we get the forward vector without the y-component so we effectively have a top-down view of the direction. In the case below, the blue axis is the camera’s forward which happens to align with the world’s z-axis.

Next, we need to apply some offset along the transform’s right axis (which in the diagrams happens to align with the world’s x-axis). Thus, we calculate a random value in the [0,1] range and use this to lerp between the vector pointing to the left of the camera and vector pointing the right of the camera. Thus, our resulting vector will point toward one of the points along the black arc demonstrated below with a zero y component.

Finally, we perform this same random lerping operation but instead along the world’s up axis which will range from pointing downward to pointing directly upward. This random up-offset will combine with our locked xz vector to give us a full 3D world vector.

Validating a proposed placement point:

Once we have a potential world position to place a target, we want to validate that an item can be placed there. The primary restriction here is that no other Target is within some distance of our proposed world position. Thus, the helper function bool IsValidPlacement(Vector3 pos) searches all active Target objects in the scene and checks whether any are within some distance, which is effectively a sphere of MinDistanceSpace radius. If none are near our proposed position, then we return true.

NOTE: Again, we are only comparing the squared distance values instead of the actual distance between our proposed point and any active target position. If both our distance between points and our distance threshold variables are squared, then the length comparison is valid. This saves us from doing a sqrt() operation on the vector that goes from a given Target to our propose position.

Placing bulls-eye targets:

Now that we have our helper methods outlined, we can start crafting the logic to perform the raycasts and placements. First, we have to see if a bulls-eye marker is available in our object pool to request. If so, then we shoot a raycast starting from our camera and to a random direction. Note we are filtering the raycast to only include valid hits within a MAX_RAYCAST_DISTANCE const limit as well as against our layerMask property defined in editor. If we hit a valid point on the spatial mapping mesh, then we next check if the hit point is far enough way based on MIN_RAYCAST_DISTANCE. We do not want to try to place any Targets that are abnormally close to the user’s face/position.

If all of the above conditions pass, then we can generate a proposed placement position. This position is calculated as the point of the raycast hit on the spatial mesh plus some offset (i.e HIT_OFFSET) away from the mesh using the hit point’s normal vector. If no other active Targets lie within the vicinity of this proposed point, then it is a valid position.

We request a new bulls-eye Target object from our pool and then set it’s position at our placement point and orientation as the direction facing away from the mesh at the point the raycast hit (i.e hit.normal).

Placing ring targets:

Placing a Ring Target type into the scene is more or less the same process. However, there are some important differences. First, instead of performing a simple ray cast operation with Unity, we want to use a SphereCast. A SphereCast behaves similarly as a ray cast in that there is an origin and direction but we effectively shoot a sphere along this ray instead of just a line. Thus, if any colliders lie along this “capsule” that we shoot, then there will be a SphereCast hit.

The reasoning for using this operation instead is that we want to place the Ring Targets in mid-air and not on a wall/surface. This is to ensure there is spacing for the ball to launch through the rings. A ray cast could shoot between two surfaces and thus our “mid-air” proposed position could actually overlap with the spatial map mesh. A sphere-cast ensures we have a clear line of sight through the space to our impact point. Just like our RayCast in PlaceMarker() we want only valid hit points within some min/max range of distance and only on surfaces defined by our layerMask property (i.e the spatial mapping mesh).

If all of these conditions are met, then we calculate our proposed position as the midpoint between ourselves (i.e the camera position) and the point of impact with the SphereCast (i.e hit.point). We test if this position is valid just as in PlaceMarker() and if so, we likewise request a Ring Target from our object pool and place it in the scene. For orienting the Ring, instead of using the normal of the point of impact, we just use the direction of our SphereCast for alignment.

WorldAnchors:

Finally, once we have passed all the gates in our algorithm above, we want to lock our Target at the position and orientation calculated. From Part 4, we added the necessary code to utilize WorldAnchor components. After placing our Target GameObject, we want to access the Target script and lock it using the Lock() function. It will Unlock() itself when disabled which will occur once the player has captured the target or the Target’s timer expires.

Performance Notes:

It should be noted that the TargetManager component can turn into a bottleneck depending on it’s configuration for performance on device. If the timer values are low and there are multiple attempts to raycast into the scene, then this will impact performance. Furthermore, if there are already a large number of active targets in the scene, then this scales the distance check for every proposed point attempt.

Review notes

This is a very simple design. Depending on our random values returned and the current scene environment, we could have multiple missed ray-cast operations performed and thus no targets placed. The bulls-eye raycast could be converted into a SphereCast which is more expensive but could ensure that the closest mesh component is hit. However, there may be random “noisy” polygons that could impact either of these Raycast/SphereCast results that we do not desire. As an example, we may hit a wall but there are some other polygon mesh data overlapping with the place target making it difficult to hit with the ball. The min/max ray-cast distance thresholds are hard-coded, etc. It is recommended to the reader to attempt to improve this class’ design, algorithm, and style. Try different approaches and see what works!

Previous section: [Part 4] — Building the Targets

Next section: [Part 6] — Creating the score board

Table of Contents

References

--

--

Troy Ferrell

AR/VR Software Engineer, passion for computer graphics and performance optimization.