Render AR guidance
With a Site selected and VPS2 localization running, this page extends the Unity AR scene with AR status labels from the previous page, a billboard destination marker, a ground chevron trail, and a downloaded mesh that appears only during refined tracking.

Figure: Render AR guidance uses the tracked Site pose to update the destination marker and ground chevrons, then displays downloaded mesh content during refined tracking.
This page has four sections:
- Add markers, chevrons, and meshes.
- Publish tracked anchor data.
- Render advanced AR guidance.
- Download and render meshes.
Files overview
In the standalone Unity app shown in this tutorial, Assets/Scenes/WayfindingAR.unity contains the AR Foundation objects, and Assets/Scripts/WayfindingAR/WayfindingARSceneManager.cs owns the selected Site, tracked anchor, destination guidance, and precise mesh state used during this step.
Your app can organize these responsibilities differently. The following table compares the standalone structure used in this tutorial with the corresponding files in the vps2-wayfinding-sample sample.
| Responsibility | Standalone app | Sample reference |
|---|---|---|
| Add markers, chevrons, and meshes | Assets/Resources/Marker/, Assets/Resources/Materials/WireframeFade.mat, Assets/Resources/Materials/WireframeFade.shader, Assets/Resources/Prefabs/WayfindingGroundMesh.prefab, Assets/Scenes/WayfindingAR.unity, Assets/Scripts/WayfindingAR/DestinationMarkerController.cs, and Assets/Scripts/WayfindingAR/ChevronGuidanceController.cs provide marker assets, mesh assets, and AR guidance helpers | Assets/Resources/Marker/, Assets/Resources/Materials/WireframeFade.mat, Assets/Resources/Materials/WireframeFade.shader, Assets/Resources/Prefabs/WayfindingGroundMesh.prefab, Assets/Scenes/WayfindingAR.unity, Assets/Scripts/WayfindingAR/DestinationMarkerController.cs, and Assets/Scripts/WayfindingAR/ChevronGuidanceController.cs |
| Publish tracked anchor data | Assets/Scripts/WayfindingAR/WayfindingARSceneManager.cs selected anchor, localization state, and mesh-download state | Assets/Scripts/WayfindingAR/WayfindingManager.cs |
| Render advanced AR guidance | Assets/Scripts/WayfindingAR/WayfindingARSceneManager.cs, Assets/Scripts/WayfindingAR/DestinationMarkerController.cs, and Assets/Scripts/WayfindingAR/ChevronGuidanceController.cs destination marker and ground chevrons | Assets/Scripts/WayfindingAR/WayfindingManager.cs, Assets/Scripts/WayfindingAR/DestinationMarkerController.cs, and Assets/Scripts/WayfindingAR/ChevronGuidanceController.cs |
| Download and render meshes | Assets/Scripts/WayfindingAR/WayfindingGroundMeshSetup.cs, Assets/Scripts/WayfindingAR/WayfindingARSceneManager.cs, and Assets/Scenes/WayfindingAR.unity ground-only mesh filtering and refined-tracking-only rendering | Assets/Scripts/WayfindingAR/WayfindingGroundMeshSetup.cs and Assets/Scripts/WayfindingAR/WayfindingManager.cs |
Add markers, chevrons, and meshes
This step adds the marker and mesh assets the AR scene uses, then creates the helper scripts that later steps will move from the tracked anchor.
- Copy the marker and mesh assets: Add the destination-marker prefab and mesh assets used by the AR scene.
- Create the shared AR guidance helpers: Create the helper scripts that move the destination marker and chevrons.
Copy the marker and mesh assets
Add the sample assets as follows:
-
In the Project window, create the following folders:
Assets/Resources/Marker,Assets/Resources/Materials, andAssets/Resources/Prefabs. -
Copy the following sample-reference assets into those folders:
Assets/Resources/Marker/DestinationMarker.prefabAssets/Resources/Marker/destination_marker.pngAssets/Resources/Materials/WireframeFade.matAssets/Resources/Materials/WireframeFade.shaderAssets/Resources/Prefabs/WayfindingGroundMesh.prefab
If you copy these files in Finder or another file browser instead of Unity's Project window, also copy each matching
.metafile.WireframeFade.matuses the GUID inWireframeFade.shader.metato find its shader.
Validate this part:
- Select
WireframeFade.matand confirm Shader isCustom/UniversalWireframe, notMissing.
Create the shared AR guidance helpers
Create the guidance helpers as follows:
-
Create
Assets/Scripts/WayfindingAR/DestinationMarkerController.csto place a billboard marker at the tracked destination:Expand to view
DestinationMarkerController.csusing UnityEngine;
/// <summary>
/// Positions and billboards a 2D destination marker prefab at the localized anchor.
/// </summary>
public class DestinationMarkerController : MonoBehaviour
{
// CUSTOMIZE: Adjust these values for your marker artwork and preferred screen distance.
[SerializeField]
private float _eyeLevelOffset = 0.8f;
[SerializeField]
private float _maxHorizontalDistance = 40f;
private Transform _visual;
private Transform _targetAnchor;
private Camera _camera;
private bool _visible;
public void Initialize(GameObject visualPrefab)
{
// Step 1: Create the app-specific marker prefab that this helper repositions every frame.
// Replace the prefab assigned later in the scene with your app's destination artwork.
if (visualPrefab == null)
{
Debug.LogError("DestinationMarkerController: Destination marker prefab is not assigned.");
gameObject.SetActive(false);
return;
}
var visual = Instantiate(visualPrefab, transform);
visual.transform.localPosition = Vector3.zero;
visual.transform.localRotation = Quaternion.identity;
_visual = visual.transform;
Debug.Log("DestinationMarkerController: Initialized destination marker.");
}
public void SetTargetAnchor(Transform anchor)
{
// Step 2: Store the tracked anchor that the marker should follow.
_targetAnchor = anchor;
}
public void SetVisible(bool visible)
{
// Step 3: Show or hide the whole marker helper from later tracking updates.
_visible = visible;
gameObject.SetActive(visible);
}
private void LateUpdate()
{
// Step 4: Stop early until later steps provide both a visible state and an anchor.
if (!_visible || _targetAnchor == null)
{
return;
}
// Step 5: Reuse the main AR camera so the marker can face the user.
_camera ??= Camera.main;
if (_camera == null)
{
return;
}
var cameraPosition = _camera.transform.position;
var destinationPosition = _targetAnchor.position;
var toDestination = destinationPosition - cameraPosition;
var horizontalDirection = new Vector3(toDestination.x, 0f, toDestination.z);
var horizontalDistance = horizontalDirection.magnitude;
// Step 6: Clamp distant targets so the marker stays in front of the user.
Vector3 markerPosition;
if (horizontalDistance <= _maxHorizontalDistance || horizontalDistance <= Mathf.Epsilon)
{
markerPosition = destinationPosition;
}
else
{
horizontalDirection /= horizontalDistance;
markerPosition = new Vector3(
cameraPosition.x + horizontalDirection.x * _maxHorizontalDistance,
destinationPosition.y,
cameraPosition.z + horizontalDirection.z * _maxHorizontalDistance);
}
// Step 7: Raise the marker to eye level and keep it billboarded toward the camera.
transform.position = markerPosition + Vector3.up * _eyeLevelOffset;
UpdateBillboard();
}
private void UpdateBillboard()
{
// Keep the marker facing the user without tilting it toward the camera vertically.
var toCamera = _camera.transform.position - _visual.position;
toCamera.y = 0f;
if (toCamera.sqrMagnitude <= Mathf.Epsilon)
{
return;
}
_visual.rotation = Quaternion.LookRotation(-toCamera.normalized, Vector3.up);
}
} -
Create
Assets/Scripts/WayfindingAR/ChevronGuidanceController.csto place a ground chevron trail in front of the user. This helper keeps the trail on the ground, keeps it in front of the camera, and later lets coarse guidance point toward the selected Site before refined tracking starts:Expand to view
ChevronGuidanceController.csusing UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.XR.ARFoundation;
/// <summary>
/// Flat ground chevron trail pointing toward the POI while guidance is active.
/// The generated chevron tip faces local -Z even though its transforms use Unity's
/// normal left-handed coordinate space.
/// </summary>
public class ChevronGuidanceController : MonoBehaviour
{
// CUSTOMIZE: Change these constants to match your app's trail density and chevron shape.
private const int ChevronCount = 10;
private const float BaseArmLength = 0.26f;
private const float BaseArmThickness = 0.013f;
private const float ArmDepth = 0.005f;
private const float HalfAngle = Mathf.PI / 4f;
private const float Spacing = 0.20f;
private const float TipSizeFraction = 0.75f;
// CUSTOMIZE: Tune these placement values for your camera setup and expected floor height.
[SerializeField]
private float _minShowDistanceSqr = 2f;
[SerializeField]
private float _cameraPlaneOffset = 0.3f;
[SerializeField]
private float _groundPlaneOffset = 0.01f;
[SerializeField]
private float _fallbackGroundOffset = 1f;
[SerializeField]
private float _forwardOffset = 0.5f;
[SerializeField]
private float _minViewportY = 0.12f;
[SerializeField]
private float _maxForwardOffset = 3.0f;
private ARMeshManager _meshManager;
private Transform _targetAnchor;
private Camera _camera;
private Transform _cameraTransform;
private static Shader _unlitShader;
private float _nextDebugLogTime;
public void Initialize(ARMeshManager meshManager, Transform cameraTransform)
{
// Step 1: Store the live mesh and camera references, then build the trail once.
_meshManager = meshManager;
_cameraTransform = cameraTransform;
_camera = cameraTransform != null ? cameraTransform.GetComponent<Camera>() : null;
BuildChevronGroup();
SetGuidanceEnabled(false);
}
public void SetTargetAnchor(Transform anchor)
{
// Step 2: Point the chevrons at whichever target later tracking code chooses.
_targetAnchor = anchor;
}
public void SetGuidanceEnabled(bool guidanceEnabled)
{
// Step 3: Let later tracking code show or hide the whole chevron trail.
gameObject.SetActive(guidanceEnabled);
}
private void LateUpdate()
{
// Step 4: Wait until later steps provide a tracked anchor or coarse target.
if (_targetAnchor == null)
{
return;
}
// Step 5: Reuse the AR camera transform so the chevrons stay in front of the user.
if (_cameraTransform == null)
{
_camera ??= Camera.main;
_cameraTransform = _camera != null ? _camera.transform : null;
}
else if (_camera == null)
{
_camera = _cameraTransform.GetComponent<Camera>();
}
if (_cameraTransform == null || _camera == null)
{
gameObject.SetActive(false);
return;
}
var cameraPosition = _cameraTransform.position;
var poiPosition = _targetAnchor.position;
var toPoi = poiPosition - cameraPosition;
if (toPoi.sqrMagnitude <= _minShowDistanceSqr)
{
// Step 6: Hide the trail once the user is already close to the destination.
gameObject.SetActive(false);
return;
}
// Step 7: Flatten the camera forward vector so the trail stays on the ground plane.
var cameraForwardFlat = _cameraTransform.forward;
cameraForwardFlat.y = 0f;
if (cameraForwardFlat.sqrMagnitude <= Mathf.Epsilon)
{
cameraForwardFlat = Vector3.forward;
}
else
{
cameraForwardFlat.Normalize();
}
// Step 8: Start the trail in front of the user and keep it inside the visible viewport.
var arrowPosition = ResolveArrowPosition(cameraPosition, cameraForwardFlat);
transform.position = arrowPosition;
// Step 9: Rotate the trail's local -Z tip toward the destination. ARVps2Anchor
// already supplies a Unity-space transform, so no native-to-Unity Z flip belongs here.
var toPoiFlat = new Vector3(
poiPosition.x - arrowPosition.x,
0f,
poiPosition.z - arrowPosition.z);
if (toPoiFlat.sqrMagnitude > Mathf.Epsilon)
{
transform.rotation = Quaternion.FromToRotation(Vector3.back, toPoiFlat.normalized);
}
// Step 10: Log both the requested and rendered directions for troubleshooting.
if (Time.unscaledTime >= _nextDebugLogTime)
{
var arrowViewport = _camera.WorldToViewportPoint(arrowPosition);
var poiViewport = _camera.WorldToViewportPoint(poiPosition);
var targetDirection = toPoiFlat.sqrMagnitude > Mathf.Epsilon
? toPoiFlat.normalized
: Vector3.zero;
var renderedTipDirection = transform.TransformDirection(Vector3.back);
renderedTipDirection.y = 0f;
if (renderedTipDirection.sqrMagnitude > Mathf.Epsilon)
{
renderedTipDirection.Normalize();
}
// signedErrorDegrees should stay near 0 and alignment should stay near 1.
// A result near 180/-1 means the visible chevron axis is reversed.
var signedErrorDegrees = targetDirection.sqrMagnitude > Mathf.Epsilon
? Vector3.SignedAngle(renderedTipDirection, targetDirection, Vector3.up)
: 0f;
var alignment = targetDirection.sqrMagnitude > Mathf.Epsilon
? Vector3.Dot(renderedTipDirection, targetDirection)
: 0f;
Debug.Log($"ChevronGuidanceController: Direction diagnostic targetDirection={targetDirection}, renderedTipDirection={renderedTipDirection}, signedErrorDegrees={signedErrorDegrees:F1}, alignment={alignment:F3}, camera={cameraPosition}, arrow={arrowPosition}, arrowViewport={arrowViewport}, poi={poiPosition}, poiViewport={poiViewport}, distance={Mathf.Sqrt(toPoi.sqrMagnitude):F2}");
_nextDebugLogTime = Time.unscaledTime + 0.5f;
}
}
private Vector3 ResolveArrowPosition(Vector3 cameraPosition, Vector3 cameraForwardFlat)
{
// Move the trail farther forward when its first position would extend past the viewport's lower edge.
var arrowPosition = cameraPosition + cameraForwardFlat * _forwardOffset;
arrowPosition.y = ResolveGroundY(arrowPosition);
var viewport = _camera.WorldToViewportPoint(arrowPosition);
if (viewport.z > 0f && viewport.y >= _minViewportY)
{
return arrowPosition;
}
for (var forward = _forwardOffset + 0.25f; forward <= _maxForwardOffset; forward += 0.25f)
{
var candidate = cameraPosition + cameraForwardFlat * forward;
candidate.y = ResolveGroundY(candidate);
viewport = _camera.WorldToViewportPoint(candidate);
if (viewport.z > 0f && viewport.y >= _minViewportY)
{
return candidate;
}
}
return arrowPosition;
}
private float ResolveGroundY(Vector3 samplePosition)
{
// Prefer detected AR mesh geometry, then fall back to a fixed negative vertical offset from the camera.
// Replace this fallback if your app already provides a floor or navigation surface.
if (_meshManager != null && TryResolveGroundYFromMeshBounds(samplePosition, out var meshGroundY))
{
return meshGroundY;
}
return samplePosition.y - _fallbackGroundOffset;
}
private bool TryResolveGroundYFromMeshBounds(Vector3 samplePosition, out float groundY)
{
// Find the highest active mesh surface with a smaller world-space Y value than the camera at the trail's X/Z position.
groundY = float.NegativeInfinity;
var meshes = _meshManager.meshes;
for (var i = 0; i < meshes.Count; i++)
{
var meshFilter = meshes[i];
if (meshFilter == null || !meshFilter.gameObject.activeInHierarchy)
{
continue;
}
if (!TryGetWorldBounds(meshFilter, out var bounds))
{
continue;
}
if (samplePosition.x < bounds.min.x || samplePosition.x > bounds.max.x)
{
continue;
}
if (samplePosition.z < bounds.min.z || samplePosition.z > bounds.max.z)
{
continue;
}
if (bounds.max.y >= samplePosition.y - _cameraPlaneOffset)
{
continue;
}
if (bounds.max.y > groundY)
{
groundY = bounds.max.y;
}
}
if (groundY <= float.NegativeInfinity)
{
return false;
}
groundY += _groundPlaneOffset;
return true;
}
private static bool TryGetWorldBounds(MeshFilter meshFilter, out Bounds bounds)
{
// Convert local mesh bounds into world-space bounds before comparing floor heights.
bounds = default;
var mesh = meshFilter.sharedMesh;
if (mesh == null)
{
return false;
}
var localBounds = mesh.bounds;
var matrix = meshFilter.transform.localToWorldMatrix;
var center = matrix.MultiplyPoint3x4(localBounds.center);
var extents = localBounds.extents;
var axisX = matrix.MultiplyVector(new Vector3(extents.x, 0f, 0f));
var axisY = matrix.MultiplyVector(new Vector3(0f, extents.y, 0f));
var axisZ = matrix.MultiplyVector(new Vector3(0f, 0f, extents.z));
var worldExtents = new Vector3(
Mathf.Abs(axisX.x) + Mathf.Abs(axisY.x) + Mathf.Abs(axisZ.x),
Mathf.Abs(axisX.y) + Mathf.Abs(axisY.y) + Mathf.Abs(axisZ.y),
Mathf.Abs(axisX.z) + Mathf.Abs(axisY.z) + Mathf.Abs(axisZ.z));
bounds = new Bounds(center, worldExtents * 2f);
return true;
}
private void BuildChevronGroup()
{
// Step 11: Build one tapered chevron trail that fades toward the destination.
var lastIndex = ChevronCount - 1;
for (var i = 0; i < ChevronCount; i++)
{
var t = lastIndex > 0 ? (float)i / lastIndex : 0f;
var scale = 1f - t * (1f - TipSizeFraction);
var armLength = BaseArmLength * scale;
var armThickness = BaseArmThickness * scale;
var alpha = 0.25f + 0.75f * t;
var chevron = new GameObject($"Chevron_{i}");
chevron.transform.SetParent(transform, false);
chevron.transform.localPosition = new Vector3(0f, 0f, (t - 1f) * Spacing * lastIndex);
var leftLength = armLength;
var rightLength = armLength - armThickness;
var material = CreateChevronMaterial(alpha);
CreateArm(
chevron.transform,
"LeftArm",
leftLength,
armThickness,
material,
Quaternion.Euler(0f, HalfAngle * Mathf.Rad2Deg, 0f),
new Vector3(
-Mathf.Cos(HalfAngle) * leftLength * 0.5f + Mathf.Sin(HalfAngle) * armThickness * 0.5f,
0f,
Mathf.Sin(HalfAngle) * leftLength * 0.5f + Mathf.Cos(HalfAngle) * armThickness * 0.5f));
var rightCenter = rightLength * 0.5f + armThickness;
CreateArm(
chevron.transform,
"RightArm",
rightLength,
armThickness,
material,
Quaternion.Euler(0f, -HalfAngle * Mathf.Rad2Deg, 0f),
new Vector3(
Mathf.Cos(HalfAngle) * rightCenter - Mathf.Sin(HalfAngle) * armThickness * 0.5f,
0f,
Mathf.Sin(HalfAngle) * rightCenter + Mathf.Cos(HalfAngle) * armThickness * 0.5f));
}
}
private static void CreateArm(
Transform parent,
string name,
float length,
float thickness,
Material material,
Quaternion localRotation,
Vector3 localPosition)
{
var arm = GameObject.CreatePrimitive(PrimitiveType.Cube);
arm.name = name;
arm.transform.SetParent(parent, false);
arm.transform.localRotation = localRotation;
arm.transform.localPosition = localPosition;
arm.transform.localScale = new Vector3(length, ArmDepth, thickness);
// Step 12: Use an unlit mesh with no shadows or probes so the trail stays lightweight.
var renderer = arm.GetComponent<MeshRenderer>();
renderer.sharedMaterial = material;
renderer.shadowCastingMode = ShadowCastingMode.Off;
renderer.receiveShadows = false;
renderer.lightProbeUsage = LightProbeUsage.Off;
renderer.reflectionProbeUsage = ReflectionProbeUsage.Off;
Destroy(arm.GetComponent<Collider>());
}
private static Material CreateChevronMaterial(float alpha)
{
// Use a lightweight unlit shader so chevrons remain visible under changing AR lighting.
if (_unlitShader == null)
{
_unlitShader = Shader.Find("Universal Render Pipeline/Unlit");
if (_unlitShader == null)
{
_unlitShader = Shader.Find("Unlit/Color");
}
}
// CUSTOMIZE: Replace white with your app's guidance color or use your own material.
var color = new Color(1f, 1f, 1f, alpha);
var material = new Material(_unlitShader)
{
color = color
};
if (material.HasProperty("_BaseColor"))
{
material.SetColor("_BaseColor", color);
}
if (material.HasProperty("_Color"))
{
material.SetColor("_Color", color);
}
material.renderQueue = (int)RenderQueue.Transparent;
if (material.HasProperty("_Surface"))
{
material.SetFloat("_Surface", 1f);
}
if (material.HasProperty("_Blend"))
{
material.SetFloat("_Blend", 0f);
}
if (material.HasProperty("_AlphaClip"))
{
material.SetFloat("_AlphaClip", 0f);
}
if (material.HasProperty("_ZWrite"))
{
material.SetFloat("_ZWrite", 0f);
}
material.SetOverrideTag("RenderType", "Transparent");
material.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
material.SetInt("_SrcBlend", (int)BlendMode.SrcAlpha);
material.SetInt("_DstBlend", (int)BlendMode.OneMinusSrcAlpha);
material.SetInt("_Cull", (int)CullMode.Off);
return material;
}
}
Validate this part:
- Let Unity finish recompiling.
- Confirm the Console has no compile errors for
DestinationMarkerController.csorChevronGuidanceController.cs.
In the vps2-wayfinding-sample sample, the equivalent helper scripts live in Assets/Scripts/WayfindingAR/DestinationMarkerController.cs and Assets/Scripts/WayfindingAR/ChevronGuidanceController.cs.
Publish tracked anchor data
This step extends WayfindingARSceneManager.cs so it can download a location mesh for the selected anchor and keep that mesh hidden until refined tracking begins. There are two steps:
- Track the selected anchor mesh: Start the location-mesh download after anchor tracking begins.
- Show the refined mesh: Keep the downloaded mesh inactive until the tracked anchor reaches refined tracking.
Track the selected anchor mesh
Update Assets/Scripts/WayfindingAR/WayfindingARSceneManager.cs as follows:
-
Add the following imports to download and render location meshes:
using System;
using System.Threading.Tasks;
using NianticSpatial.NSDK.AR.Subsystems; -
Add the following serialized field after
[SerializeField] private ARVps2Manager _arVps2Manager;to hold the location-mesh manager:[SerializeField] private LocationMeshManager _locationMeshManager; -
Add the following serialized field after
[SerializeField] private ARCameraManager _arCameraManager;to hold the AR mesh manager:[SerializeField] private ARMeshManager _arMeshManager; -
Add the following destination-marker prefab field after
[SerializeField] private Button _backButton;so later steps can spawn the destination marker:[SerializeField] private GameObject _destinationMarkerImagePrefab; -
Add the following private fields after
private ARVps2Anchor _anchor;to store the downloaded mesh and the guidance helpers:private bool _didDownloadMesh;
private GameObject _locationMesh;
private DestinationMarkerController _destinationMarker;
private ChevronGuidanceController _chevronGuidance; -
Add
OnDestroy()afterprivate void OnDisable()to remove any downloaded mesh and helper objects when the scene closes:private void OnDestroy()
{
// Remove every runtime object this manager created when the AR scene closes.
if (_destinationMarker != null)
{
Destroy(_destinationMarker.gameObject);
}
if (_chevronGuidance != null)
{
Destroy(_chevronGuidance.gameObject);
}
if (_locationMesh != null)
{
Destroy(_locationMesh);
}
} -
Update
private void StartLocalization()to start the location-mesh download after anchor tracking begins:private void StartLocalization()
{
// Track the selected anchor payload only once.
if (_anchor != null)
{
return;
}
Debug.Log($"WayfindingARSceneManager: Tracking anchor for '{_siteNameLabel.text}' (payload length {_anchorPayload.Length}).");
if (_arVps2Manager == null || !_arVps2Manager.TryTrackAnchor(_anchorPayload, out _anchor))
{
Debug.LogWarning("WayfindingARSceneManager: TryTrackAnchor failed; device geo-localization may still progress.");
return;
}
DownloadLocationMeshAsync();
} -
Add the following mesh-download helper after
private void StartLocalization()to download one location mesh for the selected anchor:Expand to view
DownloadLocationMeshAsync()private async void DownloadLocationMeshAsync()
{
// Download the location mesh only once for the selected anchor.
if (_didDownloadMesh)
{
return;
}
if (_locationMeshManager == null)
{
Debug.LogError("WayfindingARSceneManager: Location Mesh Manager is not assigned.");
return;
}
await Task.Yield();
GameObject mesh;
try
{
// Request the selected Site's location mesh without colliders or textures.
// CUSTOMIZE: Enable those options if your effect needs physics or textured geometry.
mesh = await _locationMeshManager.GetLocationMeshForPayloadAsync(_anchorPayload, 0, false, false);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception exception)
{
Debug.LogError($"WayfindingARSceneManager: Location mesh download failed: {exception.Message}");
return;
}
if (mesh == null)
{
Debug.LogError("WayfindingARSceneManager: Location mesh download failed.");
return;
}
if (_anchor == null)
{
Destroy(mesh);
return;
}
_locationMesh = mesh;
_locationMesh.transform.SetParent(_anchor.transform, false);
_locationMesh.SetActive(false);
_didDownloadMesh = true;
Debug.Log("WayfindingARSceneManager: Location mesh downloaded; visible=False until anchorState=Tracking.");
UpdateLocationMeshVisibility();
}
Show the refined mesh
Update Assets/Scripts/WayfindingAR/WayfindingARSceneManager.cs as follows:
-
Add
UpdateLocationMeshVisibility()afterprivate async void DownloadLocationMeshAsync()to show the downloaded mesh only during refined tracking:private void UpdateLocationMeshVisibility()
{
// Wait until the asynchronous mesh download has completed.
if (_locationMesh == null)
{
return;
}
var anchorTrackingState = _anchor != null
? _anchor.trackingState
: (TrackingState)(-1);
var shouldShowMesh = anchorTrackingState == TrackingState.Tracking;
// Log only transitions so troubleshooting can distinguish hidden and visible states.
if (_locationMesh.activeSelf == shouldShowMesh)
{
return;
}
_locationMesh.SetActive(shouldShowMesh);
Debug.Log($"WayfindingARSceneManager: Location mesh visibility visible={shouldShowMesh}, anchorState={anchorTrackingState}.");
} -
Update
private void Update()to refresh that precise-only mesh state every frame:private void Update()
{
// Refresh the status UI and precise-only mesh visibility every frame.
UpdateSampleInfoLabel(GetInfoMessage());
UpdateLocationMeshVisibility();
}
Validate this part:
- Let Unity finish recompiling and confirm the Console has no compile errors.
The mesh manager is assigned later, so device validation occurs after Wire the AR scene. That validation confirms the mesh remains hidden until refined tracking.
Render advanced AR guidance
This step updates the AR scene manager so it creates and moves the destination marker and the ground chevrons from the tracked anchor in the following steps:
- Update the destination marker: Show the selected Site as a billboard marker when tracking is available.
- Update the ground chevrons: Show a floor-level chevron trail while coarse guidance is active.
Update the destination marker
Update Assets/Scripts/WayfindingAR/WayfindingARSceneManager.cs as follows:
-
Add
CreateDestinationMarker()afterprivate void RefreshSessionInfoLabel()to create the destination marker from the prefab you copied earlier:private void CreateDestinationMarker()
{
// The prefab is assigned during the final scene-wiring step.
// CUSTOMIZE: Assign your app's destination marker prefab instead of the sample artwork.
if (_destinationMarkerImagePrefab == null)
{
Debug.LogError("WayfindingARSceneManager: Destination Marker Image Prefab is not assigned.");
return;
}
var markerRoot = new GameObject("DestinationMarker");
_destinationMarker = markerRoot.AddComponent<DestinationMarkerController>();
_destinationMarker.Initialize(_destinationMarkerImagePrefab);
markerRoot.SetActive(false);
} -
Add
UpdateDestinationMarker()afterprivate void CreateDestinationMarker()to move that marker with the tracked anchor:private void UpdateDestinationMarker()
{
// Wait until Start() has created the marker helper.
if (_destinationMarker == null)
{
return;
}
// Show the marker for both coarse and refined anchor poses.
// CUSTOMIZE: Replace this condition if your app presents these states differently.
var showMarker = _anchor != null
&& _anchor.trackingState is TrackingState.Limited or TrackingState.Tracking;
if (showMarker)
{
_destinationMarker.SetTargetAnchor(_anchor.transform);
}
_destinationMarker.SetVisible(showMarker);
} -
Update
private void Start()to create the destination marker when the AR scene opens:Expand to view
Start() destination-marker setupprivate void Start()
{
// Read the selected Site details and anchor payload from WayfindingSitesMap.
ApplySelectedSiteFromSession();
if (_siteNameLabel != null && string.IsNullOrWhiteSpace(_siteNameLabel.text))
{
_siteNameLabel.text = "Site Name";
}
// Create the destination marker before localization starts.
CreateDestinationMarker();
if (_backButton != null)
{
_backButton.onClick.AddListener(NavigateBackToSitesMap);
}
if (_sessionInfoPanel != null)
{
_sessionInfoPanel.SetActive(false);
}
if (_sampleInfoPanel != null)
{
_sampleInfoPanel.SetActive(false);
}
RefreshSessionInfoLabel();
UpdateSampleInfoLabel(GetInfoMessage());
// Stop here when no anchor payload was passed from the Sites scene.
if (string.IsNullOrEmpty(_anchorPayload))
{
Debug.LogWarning("WayfindingARSceneManager: No anchor payload. Select a Site on WayfindingSitesMap or set Anchor Payload in the Inspector.");
return;
}
// Start location updates before waiting for VPS2.
if (Input.location.status is LocationServiceStatus.Stopped or LocationServiceStatus.Failed)
{
Input.location.Start();
}
// Wait for the VPS2 subsystem before starting localization.
StartCoroutine(StartLocalizationWhenReady());
} -
Update
private void Update()to refresh the destination marker every frame:private void Update()
{
// Refresh the status UI, destination marker, and precise mesh every frame.
UpdateSampleInfoLabel(GetInfoMessage());
UpdateDestinationMarker();
UpdateLocationMeshVisibility();
}
Update the ground chevrons
This implementation uses the selected Site coordinates for geographic guidance during coarse localization, then hands guidance to the Limited anchor after localization becomes precise. The handoff is intentional because the flow carries Site coordinates from the map scene.
Update Assets/Scripts/WayfindingAR/WayfindingARSceneManager.cs as follows:
-
Inside the
WayfindingARSceneManagerclass, add the following constant immediately after the class's opening{. Do not add it with theusingdirectives before the class. It places a synthetic target far enough ahead of the camera for geographic guidance:// CUSTOMIZE: Change this only if your chevron helper needs a nearer or farther target.
private const float GeographicGuidanceTargetDistanceMeters = 10f;Still inside the
WayfindingARSceneManagerclass, add the following private fields afterprivate ChevronGuidanceController _chevronGuidance;. The tracking-state sentinels make the first state change appear in the device logs:private bool _lastChevronVisible;
private TrackingState _lastChevronTrackingState = (TrackingState)(-1);
private Vps2TrackingState _lastChevronLocalizationState = (Vps2TrackingState)(-1);
private float _nextChevronPoseLogTime;
private bool _hasSiteLocation;
private double _siteLatitude;
private double _siteLongitude;
private Transform _geographicGuidanceTarget;
private string _geographicGuidanceReason = "not_started";
private double _lastDeviceLatitude;
private double _lastDeviceLongitude;
private float _lastDeviceHeading;
private float _lastSiteBearing;
private float _lastRelativeBearing;
private float _lastGeolocationAccuracy;
private float _lastHeadingAccuracy;
private bool _didLogRefinedDirectionComparison;
// Once precise localization makes the VPS2 anchor reliable, keep using it for
// guidance even if the reported localization state briefly falls back to Coarse.
private bool _hasLatchedPreciseAnchorGuidance;In
OnDestroy(), add the following cleanup after the_locationMeshblock so the synthetic geographic target does not survive a scene change:if (_geographicGuidanceTarget != null)
{
Destroy(_geographicGuidanceTarget.gameObject);
} -
Add
CreateChevronGuidance()afterprivate void CreateDestinationMarker()so the helper can reuse the live AR camera instead of falling back toCamera.mainevery frame:private void CreateChevronGuidance()
{
// Live AR mesh data lets the helper place chevrons on detected ground.
if (_arMeshManager == null)
{
Debug.LogWarning("WayfindingARSceneManager: AR Mesh Manager is not assigned; chevrons will use fallback ground placement.");
}
var chevronRoot = new GameObject("ChevronGuidance");
_chevronGuidance = chevronRoot.AddComponent<ChevronGuidanceController>();
// Pass the AR camera explicitly so the helper does not search for it each frame.
_chevronGuidance.Initialize(
_arMeshManager,
_arCameraManager != null ? _arCameraManager.transform : null);
} -
Add
UpdateChevronGuidance(),TryLogRefinedDirectionComparison(),TryUpdateGeographicGuidanceTarget(), andCalculateBearingDegrees()afterprivate void UpdateDestinationMarker(). These methods use VPS2 geolocation for coarse guidance, hand off to the Limited anchor when localization becomes precise, and log the values needed to diagnose direction problems:Expand to view the geographic and precise-guidance methods
private void UpdateChevronGuidance()
{
// Wait until Start() has created the helper.
if (_chevronGuidance == null)
{
return;
}
// Read both anchor and VPS2 state so device logs explain every visibility change.
var anchorTrackingState = _anchor != null ? _anchor.trackingState : (TrackingState)(-1);
var localizationTrackingState = Vps2TrackingState.Unavailable;
if (_arVps2Manager != null && _arVps2Manager.TryGetLatestLocalization(out var localization))
{
localizationTrackingState = localization.TrackingState;
}
// During coarse localization, calculate a geographic bearing to the selected Site
// instead of using the unstable Limited anchor transform. Once VPS2 reports a precise
// localization, use that Limited anchor for the final approach because geographic
// bearing becomes unreliable inside the device's horizontal-accuracy radius.
var hasLimitedAnchor = _anchor != null && anchorTrackingState == TrackingState.Limited;
if (!_hasLatchedPreciseAnchorGuidance
&& hasLimitedAnchor
&& localizationTrackingState == Vps2TrackingState.Precise)
{
// Keep this handoff for the scene so a transient Coarse report cannot swing
// the arrows back to geographic guidance.
_hasLatchedPreciseAnchorGuidance = true;
Debug.Log("WayfindingARSceneManager: Latched precise anchor guidance for the remainder of this AR scene.");
}
var usePreciseAnchor = hasLimitedAnchor && _hasLatchedPreciseAnchorGuidance;
var hasGeographicTarget =
hasLimitedAnchor && !usePreciseAnchor && TryUpdateGeographicGuidanceTarget();
var showChevron = usePreciseAnchor
|| (hasGeographicTarget && _geographicGuidanceTarget != null);
var guidanceSource = usePreciseAnchor
? "vps2_precise_limited_anchor"
: showChevron
? "vps2_geographic_bearing"
: "hidden";
var guidanceReason = usePreciseAnchor
? localizationTrackingState == Vps2TrackingState.Precise
? "precise_localization"
: "precise_anchor_latched"
: _geographicGuidanceReason;
if (usePreciseAnchor)
{
_chevronGuidance.SetTargetAnchor(_anchor.transform);
}
else if (showChevron)
{
_chevronGuidance.SetTargetAnchor(_geographicGuidanceTarget);
}
// Log state transitions once instead of producing the same message every frame.
if (showChevron != _lastChevronVisible
|| anchorTrackingState != _lastChevronTrackingState
|| localizationTrackingState != _lastChevronLocalizationState)
{
Debug.Log($"WayfindingARSceneManager: Chevron state visible={showChevron}, anchorState={anchorTrackingState}, localizationState={localizationTrackingState}, source={guidanceSource}, reason={guidanceReason}");
_lastChevronVisible = showChevron;
_lastChevronTrackingState = anchorTrackingState;
_lastChevronLocalizationState = localizationTrackingState;
}
// Compare geographic and refined-anchor directions once tracking becomes refined.
// A large difference identifies sensor or Site-coordinate disagreement rather than
// a chevron rotation error.
if (anchorTrackingState == TrackingState.Tracking)
{
if (!_didLogRefinedDirectionComparison)
{
_didLogRefinedDirectionComparison = TryLogRefinedDirectionComparison();
}
}
else
{
_didLogRefinedDirectionComparison = false;
}
// Periodically log the active target and approach distance.
if (showChevron && Time.unscaledTime >= _nextChevronPoseLogTime)
{
var cameraTransform =
_arCameraManager != null ? _arCameraManager.transform : null;
var cameraPosition = cameraTransform != null ? cameraTransform.position : Vector3.zero;
var cameraForward = cameraTransform != null ? cameraTransform.forward : Vector3.zero;
if (usePreciseAnchor)
{
Debug.Log($"WayfindingARSceneManager: Precise anchor guidance camera={cameraPosition}, forward={cameraForward}, target={_anchor.transform.position}, distance={Vector3.Distance(cameraPosition, _anchor.transform.position):F2}");
}
else
{
Debug.Log("WayfindingARSceneManager: Geographic guidance camera=" + cameraPosition + ", forward=" + cameraForward + ", target=" + _geographicGuidanceTarget.position + ", deviceLat=" + _lastDeviceLatitude.ToString("F6") + ", deviceLon=" + _lastDeviceLongitude.ToString("F6") + ", siteLat=" + _siteLatitude.ToString("F6") + ", siteLon=" + _siteLongitude.ToString("F6") + ", heading=" + _lastDeviceHeading.ToString("F1") + ", bearing=" + _lastSiteBearing.ToString("F1") + ", relativeBearing=" + _lastRelativeBearing.ToString("F1") + ", locationAccuracy=" + _lastGeolocationAccuracy.ToString("F1") + ", headingAccuracy=" + _lastHeadingAccuracy.ToString("F1"));
}
_nextChevronPoseLogTime = Time.unscaledTime + 0.5f;
}
_chevronGuidance.SetGuidanceEnabled(showChevron);
}
private bool TryLogRefinedDirectionComparison()
{
var cameraTransform = _arCameraManager != null ? _arCameraManager.transform : null;
if (cameraTransform == null || _anchor == null)
{
return false;
}
// Refresh the geographic target without displaying it so both directions are
// measured in the current Unity world coordinate frame after refinement.
if (!TryUpdateGeographicGuidanceTarget() || _geographicGuidanceTarget == null)
{
return false;
}
var geographicDirection = _geographicGuidanceTarget.position - cameraTransform.position;
geographicDirection.y = 0f;
var refinedAnchorDirection = _anchor.transform.position - cameraTransform.position;
refinedAnchorDirection.y = 0f;
if (geographicDirection.sqrMagnitude <= Mathf.Epsilon
|| refinedAnchorDirection.sqrMagnitude <= Mathf.Epsilon)
{
return false;
}
geographicDirection.Normalize();
refinedAnchorDirection.Normalize();
var signedDifferenceDegrees = Vector3.SignedAngle(
geographicDirection,
refinedAnchorDirection,
Vector3.up);
var alignment = Vector3.Dot(geographicDirection, refinedAnchorDirection);
Debug.Log(
$"WayfindingARSceneManager: Refined direction diagnostic geographicDirection={geographicDirection}, refinedAnchorDirection={refinedAnchorDirection}, signedDifferenceDegrees={signedDifferenceDegrees:F1}, alignment={alignment:F3}, anchorDistance={Vector3.Distance(cameraTransform.position, _anchor.transform.position):F2}, locationAccuracy={_lastGeolocationAccuracy:F1}, headingAccuracy={_lastHeadingAccuracy:F1}");
return true;
}
private bool TryUpdateGeographicGuidanceTarget()
{
// CUSTOMIZE: Replace this method when your app has a route or navigation provider.
// This tutorial calculates a direct bearing to the selected Site.
if (!_hasSiteLocation)
{
_geographicGuidanceReason = "missing_site_coordinates";
return false;
}
var cameraTransform = _arCameraManager != null ? _arCameraManager.transform : null;
if (cameraTransform == null)
{
_geographicGuidanceReason = "missing_ar_camera";
return false;
}
// HeadingMode.CameraDirection returns heading aligned with the AR camera. This avoids
// mixing a native compass direction with Unity's left-handed world coordinates.
if (_arVps2Manager == null
|| !_arVps2Manager.TryGetDeviceGeolocation(
out var deviceGeolocation,
HeadingMode.CameraDirection)
|| deviceGeolocation.TrackingState == Vps2TrackingState.Unavailable)
{
_geographicGuidanceReason = "geolocation_unavailable";
return false;
}
var location = deviceGeolocation.Geolocation;
if (double.IsNaN(location.Latitude)
|| double.IsNaN(location.Longitude)
|| double.IsNaN(location.Heading))
{
_geographicGuidanceReason = "invalid_geolocation";
return false;
}
var siteBearing = CalculateBearingDegrees(
location.Latitude,
location.Longitude,
_siteLatitude,
_siteLongitude);
var relativeBearing = Mathf.DeltaAngle((float)location.Heading, (float)siteBearing);
var cameraForward = cameraTransform.forward;
cameraForward.y = 0f;
if (cameraForward.sqrMagnitude <= Mathf.Epsilon)
{
_geographicGuidanceReason = "invalid_camera_forward";
return false;
}
cameraForward.Normalize();
// Unity uses a left-handed world coordinate system, but AngleAxis around Vector3.up
// applies the camera-relative bearing directly; do not negate or flip Z here.
var worldDirection = Quaternion.AngleAxis(relativeBearing, Vector3.up) * cameraForward;
if (_geographicGuidanceTarget == null)
{
_geographicGuidanceTarget = new GameObject("GeographicGuidanceTarget").transform;
}
_geographicGuidanceTarget.position =
cameraTransform.position + worldDirection * GeographicGuidanceTargetDistanceMeters;
_lastDeviceLatitude = location.Latitude;
_lastDeviceLongitude = location.Longitude;
_lastDeviceHeading = (float)location.Heading;
_lastSiteBearing = (float)siteBearing;
_lastRelativeBearing = relativeBearing;
_lastGeolocationAccuracy = deviceGeolocation.HorizontalAccuracy;
_lastHeadingAccuracy = deviceGeolocation.HeadingAccuracy;
_geographicGuidanceReason = "ready";
return true;
}
private static double CalculateBearingDegrees(
double fromLatitude,
double fromLongitude,
double toLatitude,
double toLongitude)
{
// Calculate the initial great-circle bearing between two latitude/longitude points.
var fromLatitudeRadians = fromLatitude * Mathf.Deg2Rad;
var toLatitudeRadians = toLatitude * Mathf.Deg2Rad;
var deltaLongitudeRadians = (toLongitude - fromLongitude) * Mathf.Deg2Rad;
var y = Math.Sin(deltaLongitudeRadians) * Math.Cos(toLatitudeRadians);
var x =
Math.Cos(fromLatitudeRadians) * Math.Sin(toLatitudeRadians) -
Math.Sin(fromLatitudeRadians) * Math.Cos(toLatitudeRadians) * Math.Cos(deltaLongitudeRadians);
return (Math.Atan2(y, x) * Mathf.Rad2Deg + 360d) % 360d;
} -
Update
ApplySelectedSiteFromSession()so the AR scene reads the Site coordinates that the previous page stored for coarse guidance:private void ApplySelectedSiteFromSession()
{
// CUSTOMIZE: Replace WayfindingSessionData with your app's navigation or state container.
// Read the selected Site only when WayfindingSitesMap launched this scene.
if (!WayfindingSessionData.FromSitesMapNavigation)
{
return;
}
// Show the selected Site name in the AR scene.
if (_siteNameLabel != null)
{
_siteNameLabel.text = WayfindingSessionData.SiteName;
}
// Reuse the selected Site coordinates to steer coarse guidance before refined tracking starts.
_anchorPayload = WayfindingSessionData.AnchorPayload;
_hasSiteLocation = WayfindingSessionData.HasSiteLocation;
_siteLatitude = WayfindingSessionData.SiteLatitude;
_siteLongitude = WayfindingSessionData.SiteLongitude;
WayfindingSessionData.Clear();
} -
In
Start(), add the following call immediately afterCreateDestinationMarker()so the chevron helper exists before localization updates begin.TryGetDeviceGeolocation(..., HeadingMode.CameraDirection)supplies the heading, so this implementation does not enable Unity's separate compass API:CreateChevronGuidance(); -
In
Update(), add the following call immediately afterUpdateDestinationMarker()so chevron visibility and direction follow the latest tracking state:UpdateChevronGuidance();
At the end of this section, the code has created the geographic-to-anchor guidance logic, but the scene still has not assigned the mesh and marker references those helpers need. Visual validation for this step will be completed at the end of the next section.
Download and render meshes
This step wires the AR scene so the downloaded mesh uses ground-only filtering and appears only during refined tracking in the following steps:
- Create the ground-mesh setup helper: Filter the live AR mesh to ground surfaces.
- Wire the AR scene: Add the mesh components and assign the marker and mesh references in
WayfindingAR.unity.
Create the ground-mesh setup helper
Create Assets/Scripts/WayfindingAR/WayfindingGroundMeshSetup.cs to configure the ground-only mesh filter:
Expand to view WayfindingGroundMeshSetup.cs
using System.Collections.Generic;
using NianticSpatial.NSDK.AR.Meshing;
using NianticSpatial.NSDK.AR.Subsystems.SceneSegmentation;
using UnityEngine;
/// <summary>
/// Configures NSDK meshing to build ground-only meshes using scene segmentation.
/// </summary>
public class WayfindingGroundMeshSetup : MonoBehaviour
{
[SerializeField]
private NsdkMeshingExtension _meshingExtension;
private void Awake()
{
// Step 1: Reuse the Nsdk Meshing Extension on this same MeshManager object.
if (_meshingExtension == null)
{
_meshingExtension = GetComponent<NsdkMeshingExtension>();
}
if (_meshingExtension == null)
{
Debug.LogError("WayfindingGroundMeshSetup: Missing Nsdk Meshing Extension.");
enabled = false;
return;
}
// Step 2: Keep only ground-like scene-segmentation channels in the live AR mesh.
// CUSTOMIZE: Replace this allow list if your effect also needs walls or other surfaces.
_meshingExtension.AllowList = new List<SceneSegmentationChannel>
{
SceneSegmentationChannel.Ground,
SceneSegmentationChannel.NaturalGround,
SceneSegmentationChannel.ArtificialGround,
};
// Step 3: Turn on mesh filtering so NSDK applies the previous allow list.
_meshingExtension.IsMeshFilteringEnabled = true;
// Step 4: Use the allow list and leave the block list disabled.
_meshingExtension.IsFilteringAllowListEnabled = true;
_meshingExtension.IsFilteringBlockListEnabled = false;
}
}
Wire the AR scene
Open Assets/Scenes/WayfindingAR.unity and wire the AR scene as follows:
- Expand
XR Origin/Camera Offset, createGameObject > Create Empty, and name itMeshManagerto hold the live AR mesh components. In its Transform, set Position and Rotation to0, 0, 0, and Scale to1, 1, 1so generated mesh geometry remains in AR world scale. - Select the nested
XR Origin/Camera Offset/Main Camera, then use Add Component to attachAR Scene Segmentation Manager. The meshing extension needs this component to classify ground surfaces. - Select
MeshManager, then use Add Component to attachAR Mesh Manager,Nsdk Meshing Extension, andWayfindingGroundMeshSetup. - In
MeshManager'sAR Mesh Manager, set Mesh Prefab toAssets/Resources/Prefabs/WayfindingGroundMesh.prefabso each generated surface uses the tutorial's ground-mesh setup, then set Density to0.5. - In
MeshManager'sNsdk Meshing Extension, drag the nestedMain Camerainto Scene Segmentation Manager so the ground allow list can be applied without a runtime error. - Select
WayfindingARControllerin the Hierarchy, then use Add Component to attachLocationMeshManager. - In the
LocationMeshManagercomponent onWayfindingARController, dragAssets/Resources/Materials/WireframeFade.matinto Vertex Color Material and into Textured Mesh Material so downloaded location meshes use the copied wireframe shader. - In the
WayfindingARSceneManagercomponent, drag theLocationMeshManagercomponent into Location Mesh Manager so the scene manager can download the selected Site's location mesh. - Drag
MeshManagerinto AR Mesh Manager onWayfindingARSceneManagerso the guidance controller can use live ground meshes. - Drag
Assets/Resources/Marker/DestinationMarker.prefabinto Destination Marker Image Prefab so the scene manager can create the destination marker after tracking starts.
Validate this part:
- Build and run the complete app flow on a device.
- Grant camera and location access, complete sign-in, and confirm the Sites map loads with Site pins.
- Tap a Site pin and confirm the popup shows the selected Site name and
VPS Asset Typebefore tapping Navigate. - Confirm the AR scene shows the camera view, selected Site name, localization status, and back button.
- During coarse tracking, confirm the destination marker and chevrons appear and guide you toward the selected Site. When localization becomes precise, guidance should continue toward the Site without switching back to the geographic direction.
- During refined tracking, confirm the destination marker and location mesh remain visible while the chevrons disappear.
- In the device log, confirm that
Location mesh downloaded; visible=Falseis followed byLocation mesh visibility visible=True, anchorState=Tracking. - Tap the back button and confirm the Sites map reopens.
Coarse and refined tracking require a VPS-enabled Site and suitable device motion or playback.
Troubleshoot the Unity app
Use device logs only when the visible validation in this guide does not succeed. Reproduce the problem, then open the Unity logs using the method for your build target:
-
Android: Run the following command:
adb logcat | grep -E "WayfindingLandingController|WayfindingMapboxMapManager|WayfindingARSceneManager|DestinationMarkerController|ChevronGuidanceController|WayfindingGroundMeshSetup|ARSceneSegmentationManager|NullReferenceException" -
iOS: Run the generated project on your device from Xcode. Choose View > Debug Area > Activate Console, then enter
Wayfindingin the console filter field. UnityDebug.Log,Debug.LogWarning, andDebug.LogErrormessages appear in this console.
Check the messages that match the symptom:
| Symptom | Device-log checks |
|---|---|
The camera reports ERROR_CAMERA_DISABLED on the first launch | Grant camera and location permissions, close the app, and relaunch it before treating this as a camera failure. The first permission transition can temporarily stop the camera. |
| The Sites map does not open after sign-in | Confirm WayfindingLandingController: Loading WayfindingSitesMap. If it is absent, check the preceding permission and authentication messages. |
| The AR scene does not open | Confirm Loading WayfindingAR for and Opened WayfindingAR scene. |
| Localization does not start | Confirm Tracking anchor for. Look for No anchor payload, TryTrackAnchor failed, or Timed out waiting for VPS2 subsystem. |
| Localization status does not update | Confirm Localization info 'Localizing...'. If it is absent after navigation, recheck the localization callback and status-label assignment. |
| A Site pin is missing | Compare Loaded N VPS-ready sites, Framed N Site, and Rendered N site markers. For each missing pin, check whether Marker diagnostic reports insideViewport=False. |
| Chevrons point incorrectly or change direction suddenly | Confirm guidance changes from source=vps2_geographic_bearing to Latched precise anchor guidance and source=vps2_precise_limited_anchor. After the latch, it should not return to the geographic source. Direction diagnostic should report signedErrorDegrees near 0 and alignment near 1. |
| Chevrons do not lead toward the Site | Check the location and heading accuracy in Geographic guidance. After the precise handoff, the distance in Precise anchor guidance should generally decrease as you approach the Site. |
| The location mesh does not appear | Confirm Location mesh downloaded; visible=False is followed by Location mesh visibility visible=True, anchorState=Tracking. |
| A helper is not wired correctly | Look for an unassigned manager or prefab, a missing ARSceneSegmentationManager, or a NullReferenceException. |