Skip to main content

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.

Wayfinding workflow with Render AR guidance highlighted as the current step.

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:

  1. Add markers, chevrons, and meshes.
  2. Publish tracked anchor data.
  3. Render advanced AR guidance.
  4. 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.

ResponsibilityStandalone appSample reference
Add markers, chevrons, and meshesAssets/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 helpersAssets/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 dataAssets/Scripts/WayfindingAR/WayfindingARSceneManager.cs selected anchor, localization state, and mesh-download stateAssets/Scripts/WayfindingAR/WayfindingManager.cs
Render advanced AR guidanceAssets/Scripts/WayfindingAR/WayfindingARSceneManager.cs, Assets/Scripts/WayfindingAR/DestinationMarkerController.cs, and Assets/Scripts/WayfindingAR/ChevronGuidanceController.cs destination marker and ground chevronsAssets/Scripts/WayfindingAR/WayfindingManager.cs, Assets/Scripts/WayfindingAR/DestinationMarkerController.cs, and Assets/Scripts/WayfindingAR/ChevronGuidanceController.cs
Download and render meshesAssets/Scripts/WayfindingAR/WayfindingGroundMeshSetup.cs, Assets/Scripts/WayfindingAR/WayfindingARSceneManager.cs, and Assets/Scenes/WayfindingAR.unity ground-only mesh filtering and refined-tracking-only renderingAssets/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 sample assets as follows:

  1. In the Project window, create the following folders: Assets/Resources/Marker, Assets/Resources/Materials, and Assets/Resources/Prefabs.

  2. Copy the following sample-reference assets into those folders:

    • Assets/Resources/Marker/DestinationMarker.prefab
    • Assets/Resources/Marker/destination_marker.png
    • Assets/Resources/Materials/WireframeFade.mat
    • Assets/Resources/Materials/WireframeFade.shader
    • Assets/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 .meta file. WireframeFade.mat uses the GUID in WireframeFade.shader.meta to find its shader.

Validate this part:

  • Select WireframeFade.mat and confirm Shader is Custom/UniversalWireframe, not Missing.

Create the shared AR guidance helpers

Create the guidance helpers as follows:

  1. Create Assets/Scripts/WayfindingAR/DestinationMarkerController.cs to place a billboard marker at the tracked destination:

    Expand to view DestinationMarkerController.cs
    using 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);
    }
    }
  2. Create Assets/Scripts/WayfindingAR/ChevronGuidanceController.cs to 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.cs
    using 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:

  1. Let Unity finish recompiling.
  2. Confirm the Console has no compile errors for DestinationMarkerController.cs or ChevronGuidanceController.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

Update Assets/Scripts/WayfindingAR/WayfindingARSceneManager.cs as follows:

  1. Add the following imports to download and render location meshes:

    using System;
    using System.Threading.Tasks;
    using NianticSpatial.NSDK.AR.Subsystems;
  2. Add the following serialized field after [SerializeField] private ARVps2Manager _arVps2Manager; to hold the location-mesh manager:

    [SerializeField] private LocationMeshManager _locationMeshManager;
  3. Add the following serialized field after [SerializeField] private ARCameraManager _arCameraManager; to hold the AR mesh manager:

    [SerializeField] private ARMeshManager _arMeshManager;
  4. Add the following destination-marker prefab field after [SerializeField] private Button _backButton; so later steps can spawn the destination marker:

    [SerializeField] private GameObject _destinationMarkerImagePrefab;
  5. 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;
  6. Add OnDestroy() after private 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);
    }
    }
  7. 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();
    }
  8. 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:

  1. Add UpdateLocationMeshVisibility() after private 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}.");
    }
  2. 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

Update Assets/Scripts/WayfindingAR/WayfindingARSceneManager.cs as follows:

  1. Add CreateDestinationMarker() after private 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);
    }
  2. Add UpdateDestinationMarker() after private 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);
    }
  3. Update private void Start() to create the destination marker when the AR scene opens:

    Expand to view Start() destination-marker setup
    private 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());
    }
  4. 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:

  1. Inside the WayfindingARSceneManager class, add the following constant immediately after the class's opening {. Do not add it with the using directives 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 WayfindingARSceneManager class, add the following private fields after private 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 _locationMesh block so the synthetic geographic target does not survive a scene change:

    if (_geographicGuidanceTarget != null)
    {
    Destroy(_geographicGuidanceTarget.gameObject);
    }
  2. Add CreateChevronGuidance() after private void CreateDestinationMarker() so the helper can reuse the live AR camera instead of falling back to Camera.main every 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);
    }
  3. Add UpdateChevronGuidance(), TryLogRefinedDirectionComparison(), TryUpdateGeographicGuidanceTarget(), and CalculateBearingDegrees() after private 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;
    }
  4. 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();
    }
  5. In Start(), add the following call immediately after CreateDestinationMarker() 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();
  6. In Update(), add the following call immediately after UpdateDestinationMarker() 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

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:

  1. Expand XR Origin/Camera Offset, create GameObject > Create Empty, and name it MeshManager to hold the live AR mesh components. In its Transform, set Position and Rotation to 0, 0, 0, and Scale to 1, 1, 1 so generated mesh geometry remains in AR world scale.
  2. Select the nested XR Origin/Camera Offset/Main Camera, then use Add Component to attach AR Scene Segmentation Manager. The meshing extension needs this component to classify ground surfaces.
  3. Select MeshManager, then use Add Component to attach AR Mesh Manager, Nsdk Meshing Extension, and WayfindingGroundMeshSetup.
  4. In MeshManager's AR Mesh Manager, set Mesh Prefab to Assets/Resources/Prefabs/WayfindingGroundMesh.prefab so each generated surface uses the tutorial's ground-mesh setup, then set Density to 0.5.
  5. In MeshManager's Nsdk Meshing Extension, drag the nested Main Camera into Scene Segmentation Manager so the ground allow list can be applied without a runtime error.
  6. Select WayfindingARController in the Hierarchy, then use Add Component to attach LocationMeshManager.
  7. In the LocationMeshManager component on WayfindingARController, drag Assets/Resources/Materials/WireframeFade.mat into Vertex Color Material and into Textured Mesh Material so downloaded location meshes use the copied wireframe shader.
  8. In the WayfindingARSceneManager component, drag the LocationMeshManager component into Location Mesh Manager so the scene manager can download the selected Site's location mesh.
  9. Drag MeshManager into AR Mesh Manager on WayfindingARSceneManager so the guidance controller can use live ground meshes.
  10. Drag Assets/Resources/Marker/DestinationMarker.prefab into Destination Marker Image Prefab so the scene manager can create the destination marker after tracking starts.

Validate this part:

  1. Build and run the complete app flow on a device.
    1. Grant camera and location access, complete sign-in, and confirm the Sites map loads with Site pins.
    2. Tap a Site pin and confirm the popup shows the selected Site name and VPS Asset Type before tapping Navigate.
    3. Confirm the AR scene shows the camera view, selected Site name, localization status, and back button.
    4. 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.
    5. During refined tracking, confirm the destination marker and location mesh remain visible while the chevrons disappear.
    6. In the device log, confirm that Location mesh downloaded; visible=False is followed by Location mesh visibility visible=True, anchorState=Tracking.
    7. 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 Wayfinding in the console filter field. Unity Debug.Log, Debug.LogWarning, and Debug.LogError messages appear in this console.

Check the messages that match the symptom:

SymptomDevice-log checks
The camera reports ERROR_CAMERA_DISABLED on the first launchGrant 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-inConfirm WayfindingLandingController: Loading WayfindingSitesMap. If it is absent, check the preceding permission and authentication messages.
The AR scene does not openConfirm Loading WayfindingAR for and Opened WayfindingAR scene.
Localization does not startConfirm Tracking anchor for. Look for No anchor payload, TryTrackAnchor failed, or Timed out waiting for VPS2 subsystem.
Localization status does not updateConfirm Localization info 'Localizing...'. If it is absent after navigation, recheck the localization callback and status-label assignment.
A Site pin is missingCompare 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 suddenlyConfirm 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 SiteCheck 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 appearConfirm Location mesh downloaded; visible=False is followed by Location mesh visibility visible=True, anchorState=Tracking.
A helper is not wired correctlyLook for an unassigned manager or prefab, a missing ARSceneSegmentationManager, or a NullReferenceException.