Events
May 19, 6 PM - May 23, 12 AM
Calling all developers, creators, and AI innovators to join us in Seattle @Microsoft Build May 19-22.
Register todayThis browser is no longer supported.
Upgrade to Microsoft Edge to take advantage of the latest features, security updates, and technical support.
Important
The Mixed Reality Academy tutorials were designed with HoloLens (1st gen), Unity 2017, and Mixed Reality Immersive Headsets in mind. As such, we feel it is important to leave these tutorials in place for developers who are still looking for guidance in developing for those devices. These tutorials will not be updated with the latest toolsets or interactions being used for HoloLens 2 and may not be compatible with newer versions of Unity. They will be maintained to continue working on the supported devices. A new series of tutorials has been posted for HoloLens 2.
Holograms are given presence in our world by remaining in place as we move about in space. HoloLens keeps holograms in place by using various coordinate systems to keep track of the location and orientation of objects. When we share these coordinate systems between devices, we can create a shared experience that allows us to take part in a shared holographic world.
In this tutorial, we will:
Course | HoloLens | Immersive headsets |
---|---|---|
MR Sharing 240: Multiple HoloLens devices | ✔️ |
Note
If you want to look through the source code before downloading, it's available on GitHub.
In this chapter, we'll setup our first Unity project and step through the build and deploy process.
Export the project from Unity to Visual Studio
In this chapter, we'll interact with our holograms. First, we'll add a cursor to visualize our Gaze. Then, we'll add Gestures and use our hand to place our holograms in space.
Gaze
Gesture
Deploy and enjoy
It's fun to see and interact with holograms, but let's go further. We'll set up our first shared experience - a hologram everyone can see together.
Note
The InternetClientServer and PrivateNetworkClientServer capabilities must be declared for an app to connect to the sharing server. This is done for you already in Holograms 240, but keep this in mind for your own projects.
- In the Unity Editor, go to the player settings by navigating to "Edit > Project Settings > Player"
- Click on the "Windows Store" tab
- In the "Publishing Settings > Capabilities" section, check the InternetClientServer capability and the PrivateNetworkClientServer capability
Next we need to launch the sharing service. Only one PC in the shared experience needs to do this step.
Follow the rest of the instructions on all PCs that will join the shared experience.
using UnityEngine;
using System.Collections.Generic;
using UnityEngine.Windows.Speech;
using Academy.HoloToolkit.Unity;
using Academy.HoloToolkit.Sharing;
public class HologramPlacement : Singleton<HologramPlacement>
{
/// <summary>
/// Tracks if we have been sent a transform for the anchor model.
/// The anchor model is rendered relative to the actual anchor.
/// </summary>
public bool GotTransform { get; private set; }
private bool animationPlayed = false;
void Start()
{
// We care about getting updates for the anchor transform.
CustomMessages.Instance.MessageHandlers[CustomMessages.TestMessageID.StageTransform] = this.OnStageTransform;
// And when a new user join we will send the anchor transform we have.
SharingSessionTracker.Instance.SessionJoined += Instance_SessionJoined;
}
/// <summary>
/// When a new user joins we want to send them the relative transform for the anchor if we have it.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void Instance_SessionJoined(object sender, SharingSessionTracker.SessionJoinedEventArgs e)
{
if (GotTransform)
{
CustomMessages.Instance.SendStageTransform(transform.localPosition, transform.localRotation);
}
}
void Update()
{
if (GotTransform)
{
if (ImportExportAnchorManager.Instance.AnchorEstablished &&
animationPlayed == false)
{
// This triggers the animation sequence for the anchor model and
// puts the cool materials on the model.
GetComponent<EnergyHubBase>().SendMessage("OnSelect");
animationPlayed = true;
}
}
else
{
transform.position = Vector3.Lerp(transform.position, ProposeTransformPosition(), 0.2f);
}
}
Vector3 ProposeTransformPosition()
{
// Put the anchor 2m in front of the user.
Vector3 retval = Camera.main.transform.position + Camera.main.transform.forward * 2;
return retval;
}
public void OnSelect()
{
// Note that we have a transform.
GotTransform = true;
// And send it to our friends.
CustomMessages.Instance.SendStageTransform(transform.localPosition, transform.localRotation);
}
/// <summary>
/// When a remote system has a transform for us, we'll get it here.
/// </summary>
/// <param name="msg"></param>
void OnStageTransform(NetworkInMessage msg)
{
// We read the user ID but we don't use it here.
msg.ReadInt64();
transform.localPosition = CustomMessages.Instance.ReadVector3(msg);
transform.localRotation = CustomMessages.Instance.ReadQuaternion(msg);
// The first time, we'll want to send the message to the anchor to do its animation and
// swap its materials.
if (GotTransform == false)
{
GetComponent<EnergyHubBase>().SendMessage("OnSelect");
}
GotTransform = true;
}
public void ResetStage()
{
// We'll use this later.
}
}
Deploy and enjoy
Everyone can now see the same hologram! Now let's see everyone else connected to our shared holographic world. In this chapter, we'll grab the head location and rotation of all other HoloLens devices in the same sharing session.
using UnityEngine;
using Academy.HoloToolkit.Unity;
/// <summary>
/// Script to handle the user selecting the avatar.
/// </summary>
public class AvatarSelector : MonoBehaviour
{
/// <summary>
/// This is the index set by the PlayerAvatarStore for the avatar.
/// </summary>
public int AvatarIndex { get; set; }
/// <summary>
/// Called when the user is gazing at this avatar and air-taps it.
/// This sends the user's selection to the rest of the devices in the experience.
/// </summary>
void OnSelect()
{
PlayerAvatarStore.Instance.DismissAvatarPicker();
LocalPlayerManager.Instance.SetUserAvatar(AvatarIndex);
}
void Start()
{
// Add Billboard component so the avatar always faces the user.
Billboard billboard = gameObject.GetComponent<Billboard>();
if (billboard == null)
{
billboard = gameObject.AddComponent<Billboard>();
}
// Lock rotation along the Y axis.
billboard.PivotAxis = PivotAxis.Y;
}
}
using UnityEngine;
using System.Collections.Generic;
using UnityEngine.Windows.Speech;
using Academy.HoloToolkit.Unity;
using Academy.HoloToolkit.Sharing;
public class HologramPlacement : Singleton<HologramPlacement>
{
/// <summary>
/// Tracks if we have been sent a transform for the model.
/// The model is rendered relative to the actual anchor.
/// </summary>
public bool GotTransform { get; private set; }
/// <summary>
/// When the experience starts, we disable all of the rendering of the model.
/// </summary>
List<MeshRenderer> disabledRenderers = new List<MeshRenderer>();
void Start()
{
// When we first start, we need to disable the model to avoid it obstructing the user picking a hat.
DisableModel();
// We care about getting updates for the model transform.
CustomMessages.Instance.MessageHandlers[CustomMessages.TestMessageID.StageTransform] = this.OnStageTransform;
// And when a new user join we will send the model transform we have.
SharingSessionTracker.Instance.SessionJoined += Instance_SessionJoined;
}
/// <summary>
/// When a new user joins we want to send them the relative transform for the model if we have it.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void Instance_SessionJoined(object sender, SharingSessionTracker.SessionJoinedEventArgs e)
{
if (GotTransform)
{
CustomMessages.Instance.SendStageTransform(transform.localPosition, transform.localRotation);
}
}
/// <summary>
/// Turns off all renderers for the model.
/// </summary>
void DisableModel()
{
foreach (MeshRenderer renderer in gameObject.GetComponentsInChildren<MeshRenderer>())
{
if (renderer.enabled)
{
renderer.enabled = false;
disabledRenderers.Add(renderer);
}
}
foreach (MeshCollider collider in gameObject.GetComponentsInChildren<MeshCollider>())
{
collider.enabled = false;
}
}
/// <summary>
/// Turns on all renderers that were disabled.
/// </summary>
void EnableModel()
{
foreach (MeshRenderer renderer in disabledRenderers)
{
renderer.enabled = true;
}
foreach (MeshCollider collider in gameObject.GetComponentsInChildren<MeshCollider>())
{
collider.enabled = true;
}
disabledRenderers.Clear();
}
void Update()
{
// Wait till users pick an avatar to enable renderers.
if (disabledRenderers.Count > 0)
{
if (!PlayerAvatarStore.Instance.PickerActive &&
ImportExportAnchorManager.Instance.AnchorEstablished)
{
// After which we want to start rendering.
EnableModel();
// And if we've already been sent the relative transform, we will use it.
if (GotTransform)
{
// This triggers the animation sequence for the model and
// puts the cool materials on the model.
GetComponent<EnergyHubBase>().SendMessage("OnSelect");
}
}
}
else if (GotTransform == false)
{
transform.position = Vector3.Lerp(transform.position, ProposeTransformPosition(), 0.2f);
}
}
Vector3 ProposeTransformPosition()
{
// Put the model 2m in front of the user.
Vector3 retval = Camera.main.transform.position + Camera.main.transform.forward * 2;
return retval;
}
public void OnSelect()
{
// Note that we have a transform.
GotTransform = true;
// And send it to our friends.
CustomMessages.Instance.SendStageTransform(transform.localPosition, transform.localRotation);
}
/// <summary>
/// When a remote system has a transform for us, we'll get it here.
/// </summary>
/// <param name="msg"></param>
void OnStageTransform(NetworkInMessage msg)
{
// We read the user ID but we don't use it here.
msg.ReadInt64();
transform.localPosition = CustomMessages.Instance.ReadVector3(msg);
transform.localRotation = CustomMessages.Instance.ReadQuaternion(msg);
// The first time, we'll want to send the message to the model to do its animation and
// swap its materials.
if (disabledRenderers.Count == 0 && GotTransform == false)
{
GetComponent<EnergyHubBase>().SendMessage("OnSelect");
}
GotTransform = true;
}
public void ResetStage()
{
// We'll use this later.
}
}
using UnityEngine;
using Academy.HoloToolkit.Unity;
/// <summary>
/// Keeps track of the current state of the experience.
/// </summary>
public class AppStateManager : Singleton<AppStateManager>
{
/// <summary>
/// Enum to track progress through the experience.
/// </summary>
public enum AppState
{
Starting = 0,
WaitingForAnchor,
WaitingForStageTransform,
PickingAvatar,
Ready
}
/// <summary>
/// Tracks the current state in the experience.
/// </summary>
public AppState CurrentAppState { get; set; }
void Start()
{
// We start in the 'picking avatar' mode.
CurrentAppState = AppState.PickingAvatar;
// We start by showing the avatar picker.
PlayerAvatarStore.Instance.SpawnAvatarPicker();
}
void Update()
{
switch (CurrentAppState)
{
case AppState.PickingAvatar:
// Avatar picking is done when the avatar picker has been dismissed.
if (PlayerAvatarStore.Instance.PickerActive == false)
{
CurrentAppState = AppState.WaitingForAnchor;
}
break;
case AppState.WaitingForAnchor:
if (ImportExportAnchorManager.Instance.AnchorEstablished)
{
CurrentAppState = AppState.WaitingForStageTransform;
GestureManager.Instance.OverrideFocusedObject = HologramPlacement.Instance.gameObject;
}
break;
case AppState.WaitingForStageTransform:
// Now if we have the stage transform we are ready to go.
if (HologramPlacement.Instance.GotTransform)
{
CurrentAppState = AppState.Ready;
GestureManager.Instance.OverrideFocusedObject = null;
}
break;
}
}
}
Deploy and Enjoy
In this chapter, we'll make the anchor able to be placed on real-world surfaces. We'll use shared coordinates to place that anchor in the middle point between everyone connected to the shared experience.
using UnityEngine;
using Academy.HoloToolkit.Unity;
/// <summary>
/// Keeps track of the current state of the experience.
/// </summary>
public class AppStateManager : Singleton<AppStateManager>
{
/// <summary>
/// Enum to track progress through the experience.
/// </summary>
public enum AppState
{
Starting = 0,
PickingAvatar,
WaitingForAnchor,
WaitingForStageTransform,
Ready
}
// The object to call to make a projectile.
GameObject shootHandler = null;
/// <summary>
/// Tracks the current state in the experience.
/// </summary>
public AppState CurrentAppState { get; set; }
void Start()
{
// The shootHandler shoots projectiles.
if (GetComponent<ProjectileLauncher>() != null)
{
shootHandler = GetComponent<ProjectileLauncher>().gameObject;
}
// We start in the 'picking avatar' mode.
CurrentAppState = AppState.PickingAvatar;
// Spatial mapping should be disabled when we start up so as not
// to distract from the avatar picking.
SpatialMappingManager.Instance.StopObserver();
SpatialMappingManager.Instance.gameObject.SetActive(false);
// On device we start by showing the avatar picker.
PlayerAvatarStore.Instance.SpawnAvatarPicker();
}
public void ResetStage()
{
// If we fall back to waiting for anchor, everything needed to
// get us into setting the target transform state will be setup.
if (CurrentAppState != AppState.PickingAvatar)
{
CurrentAppState = AppState.WaitingForAnchor;
}
// Reset the underworld.
if (UnderworldBase.Instance)
{
UnderworldBase.Instance.ResetUnderworld();
}
}
void Update()
{
switch (CurrentAppState)
{
case AppState.PickingAvatar:
// Avatar picking is done when the avatar picker has been dismissed.
if (PlayerAvatarStore.Instance.PickerActive == false)
{
CurrentAppState = AppState.WaitingForAnchor;
}
break;
case AppState.WaitingForAnchor:
// Once the anchor is established we need to run spatial mapping for a
// little while to build up some meshes.
if (ImportExportAnchorManager.Instance.AnchorEstablished)
{
CurrentAppState = AppState.WaitingForStageTransform;
GestureManager.Instance.OverrideFocusedObject = HologramPlacement.Instance.gameObject;
SpatialMappingManager.Instance.gameObject.SetActive(true);
SpatialMappingManager.Instance.DrawVisualMeshes = true;
SpatialMappingDeformation.Instance.ResetGlobalRendering();
SpatialMappingManager.Instance.StartObserver();
}
break;
case AppState.WaitingForStageTransform:
// Now if we have the stage transform we are ready to go.
if (HologramPlacement.Instance.GotTransform)
{
CurrentAppState = AppState.Ready;
GestureManager.Instance.OverrideFocusedObject = shootHandler;
}
break;
}
}
}
using UnityEngine;
using System.Collections.Generic;
using UnityEngine.Windows.Speech;
using Academy.HoloToolkit.Unity;
using Academy.HoloToolkit.Sharing;
public class HologramPlacement : Singleton<HologramPlacement>
{
/// <summary>
/// Tracks if we have been sent a transform for the model.
/// The model is rendered relative to the actual anchor.
/// </summary>
public bool GotTransform { get; private set; }
/// <summary>
/// When the experience starts, we disable all of the rendering of the model.
/// </summary>
List<MeshRenderer> disabledRenderers = new List<MeshRenderer>();
/// <summary>
/// We use a voice command to enable moving the target.
/// </summary>
KeywordRecognizer keywordRecognizer;
void Start()
{
// When we first start, we need to disable the model to avoid it obstructing the user picking a hat.
DisableModel();
// We care about getting updates for the model transform.
CustomMessages.Instance.MessageHandlers[CustomMessages.TestMessageID.StageTransform] = this.OnStageTransform;
// And when a new user join we will send the model transform we have.
SharingSessionTracker.Instance.SessionJoined += Instance_SessionJoined;
// And if the users want to reset the stage transform.
CustomMessages.Instance.MessageHandlers[CustomMessages.TestMessageID.ResetStage] = this.OnResetStage;
// Setup a keyword recognizer to enable resetting the target location.
List<string> keywords = new List<string>();
keywords.Add("Reset Target");
keywordRecognizer = new KeywordRecognizer(keywords.ToArray());
keywordRecognizer.OnPhraseRecognized += KeywordRecognizer_OnPhraseRecognized;
keywordRecognizer.Start();
}
/// <summary>
/// When the keyword recognizer hears a command this will be called.
/// In this case we only have one keyword, which will re-enable moving the
/// target.
/// </summary>
/// <param name="args">information to help route the voice command.</param>
private void KeywordRecognizer_OnPhraseRecognized(PhraseRecognizedEventArgs args)
{
ResetStage();
}
/// <summary>
/// Resets the stage transform, so users can place the target again.
/// </summary>
public void ResetStage()
{
GotTransform = false;
// AppStateManager needs to know about this so that
// the right objects get input routed to them.
AppStateManager.Instance.ResetStage();
// Other devices in the experience need to know about this as well.
CustomMessages.Instance.SendResetStage();
// And we need to reset the object to its start animation state.
GetComponent<EnergyHubBase>().ResetAnimation();
}
/// <summary>
/// When a new user joins we want to send them the relative transform for the model if we have it.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void Instance_SessionJoined(object sender, SharingSessionTracker.SessionJoinedEventArgs e)
{
if (GotTransform)
{
CustomMessages.Instance.SendStageTransform(transform.localPosition, transform.localRotation);
}
}
/// <summary>
/// Turns off all renderers for the model.
/// </summary>
void DisableModel()
{
foreach (MeshRenderer renderer in gameObject.GetComponentsInChildren<MeshRenderer>())
{
if (renderer.enabled)
{
renderer.enabled = false;
disabledRenderers.Add(renderer);
}
}
foreach (MeshCollider collider in gameObject.GetComponentsInChildren<MeshCollider>())
{
collider.enabled = false;
}
}
/// <summary>
/// Turns on all renderers that were disabled.
/// </summary>
void EnableModel()
{
foreach (MeshRenderer renderer in disabledRenderers)
{
renderer.enabled = true;
}
foreach (MeshCollider collider in gameObject.GetComponentsInChildren<MeshCollider>())
{
collider.enabled = true;
}
disabledRenderers.Clear();
}
void Update()
{
// Wait till users pick an avatar to enable renderers.
if (disabledRenderers.Count > 0)
{
if (!PlayerAvatarStore.Instance.PickerActive &&
ImportExportAnchorManager.Instance.AnchorEstablished)
{
// After which we want to start rendering.
EnableModel();
// And if we've already been sent the relative transform, we will use it.
if (GotTransform)
{
// This triggers the animation sequence for the model and
// puts the cool materials on the model.
GetComponent<EnergyHubBase>().SendMessage("OnSelect");
}
}
}
else if (GotTransform == false)
{
transform.position = Vector3.Lerp(transform.position, ProposeTransformPosition(), 0.2f);
}
}
Vector3 ProposeTransformPosition()
{
Vector3 retval;
// We need to know how many users are in the experience with good transforms.
Vector3 cumulatedPosition = Camera.main.transform.position;
int playerCount = 1;
foreach (RemotePlayerManager.RemoteHeadInfo remoteHead in RemotePlayerManager.Instance.remoteHeadInfos)
{
if (remoteHead.Anchored && remoteHead.Active)
{
playerCount++;
cumulatedPosition += remoteHead.HeadObject.transform.position;
}
}
// If we have more than one player ...
if (playerCount > 1)
{
// Put the transform in between the players.
retval = cumulatedPosition / playerCount;
RaycastHit hitInfo;
// And try to put the transform on a surface below the midpoint of the players.
if (Physics.Raycast(retval, Vector3.down, out hitInfo, 5, SpatialMappingManager.Instance.LayerMask))
{
retval = hitInfo.point;
}
}
// If we are the only player, have the model act as the 'cursor' ...
else
{
// We prefer to put the model on a real world surface.
RaycastHit hitInfo;
if (Physics.Raycast(Camera.main.transform.position, Camera.main.transform.forward, out hitInfo, 30, SpatialMappingManager.Instance.LayerMask))
{
retval = hitInfo.point;
}
else
{
// But if we don't have a ray that intersects the real world, just put the model 2m in
// front of the user.
retval = Camera.main.transform.position + Camera.main.transform.forward * 2;
}
}
return retval;
}
public void OnSelect()
{
// Note that we have a transform.
GotTransform = true;
// And send it to our friends.
CustomMessages.Instance.SendStageTransform(transform.localPosition, transform.localRotation);
}
/// <summary>
/// When a remote system has a transform for us, we'll get it here.
/// </summary>
/// <param name="msg"></param>
void OnStageTransform(NetworkInMessage msg)
{
// We read the user ID but we don't use it here.
msg.ReadInt64();
transform.localPosition = CustomMessages.Instance.ReadVector3(msg);
transform.localRotation = CustomMessages.Instance.ReadQuaternion(msg);
// The first time, we'll want to send the message to the model to do its animation and
// swap its materials.
if (disabledRenderers.Count == 0 && GotTransform == false)
{
GetComponent<EnergyHubBase>().SendMessage("OnSelect");
}
GotTransform = true;
}
/// <summary>
/// When a remote system has a transform for us, we'll get it here.
/// </summary>
void OnResetStage(NetworkInMessage msg)
{
GotTransform = false;
GetComponent<EnergyHubBase>().ResetAnimation();
AppStateManager.Instance.ResetStage();
}
}
Deploy and enjoy
In this chapter we'll add holograms that bounce off real-world surfaces. Watch your space fill up with projects launched by both you and your friends!
Deploy and enjoy
In this chapter, we'll uncover a portal that can only be discovered with collaboration.
Deploy and enjoy
Events
May 19, 6 PM - May 23, 12 AM
Calling all developers, creators, and AI innovators to join us in Seattle @Microsoft Build May 19-22.
Register todayTraining
Module
This course provides you with an understanding of placing objects and tracking objects using solvers.