# Place virtual content with VPS2 Source: https://www.nianticspatial.com/docs/nsdk/how-to/vps2/placing_virtual_content/ This guide explains how to place virtual content in an AR scene relative to a [localized asset](https://www.nianticspatial.com/docs/nsdk/core_concepts/#anchor-and-place-content) or at known geo coordinates. Virtual content is any digital object rendered in AR, such as a 3D model, label, or visual effect. To appear in the correct location and remain stable as the user moves, it needs a reliable spatial reference. Depending on the placement approach, the app either attaches the content to a localized asset's origin or repeatedly calculates its AR pose from geo coordinates. When VPS2 localizes to a Site, it matches the camera view against the Site's **VPS map asset**. The asset's origin defines a tracked position and orientation in the real world. Placing virtual content relative to that origin allows the AR system to keep the content aligned with the environment over time. VPS2 handles the conversions between AR space, [map-relative space](https://www.nianticspatial.com/docs/nsdk/core_concepts/#map-relative-space), and [global space](https://www.nianticspatial.com/docs/nsdk/core_concepts/#global-space), so content placed against an asset can be expressed in whichever frame your app needs. Because the asset is a mapped real-world location, content placed relative to it appears consistently across sessions and devices. Follow this workflow: 1. [Choose a placement approach](#choose-how-to-place-virtual-content) based on where the content's position comes from and how your app needs to restore it. 2. Follow the readiness guidance for that approach. For [asset-relative placement](#place-content-relative-to-an-anchor), localize to the Site and evaluate the localized asset's tracking data and confidence. For [geo-coordinate placement](#place-content-using-geo-coordinates), evaluate the device's VPS2 tracking state and reported geolocation accuracy. 3. Use the instructions for the selected approach to place the content and save the data needed to restore it. 4. [Test the placement](#test-your-placement) and verify that the content remains stable as the AR session updates. ### Platform: unity You can optionally [use a Site mesh when positioning content](#place-content-with-imported-site-mesh), either as an asset-aligned placement guide or as a georeferenced authoring reference. Choose the approach once based on your application's placement and persistence requirements. If its readiness conditions are not met, wait for localization to improve or show a localizing state rather than switching approaches. --- ## Prerequisites Before you start, make sure you already have: - a project with NSDK installed and a basic AR scene set up - a Site in Scaniverse for workflows that localize to a Site or use Site-derived content For setup details, see [Set up the Niantic SDK](https://www.nianticspatial.com/docs/nsdk/setup/), [Set up a basic AR scene](https://www.nianticspatial.com/docs/nsdk/setup/#set-up-a-basic-ar-scene), and [First Localization with NSDK](https://www.nianticspatial.com/docs/nsdk/first_localization/). --- ## Choose how to place virtual content There are multiple ways to place virtual content in an AR scene. Each approach defines how **content is positioned**, how it **behaves over time**, and **what data is stored** so it can be reused. Choose an approach based on where the content's position comes from and how it must be restored. The current device or asset tracking state does not determine which approach to use. #### Key considerations - **Using multiple approaches** - Different features in your app can use different placement approaches. You do not need to choose only one for the entire application. - **Persistence** - Each approach defines what data, if any, is stored for reuse, and whether that reuse is limited to the current session or can work across sessions and devices: - **VPS-based placement** stores data relative to a real-world location, which can allow content to appear in the same place across sessions and devices. - **Local placement** can keep content stable during the current session, including user-driven placement such as hit tests, but it does not necessarily restore to the same real-world position later unless your app persists additional data. Use the following table to compare the available approaches based on when to use them, what data they save, and how each one restores content later. | Approach | Best for | Saved data | Restoration | | --- | --- | --- | --- | | [Place content relative to a localized asset](#place-content-relative-to-an-anchor) | You localize to a Site (by Site ID, or by coordinates for nearby Sites), then want content to stay attached to that asset's origin across sessions. | The Site ID, the asset ID, and each content item's asset-local transform. | Re-localizes to the same Site and reapplies the saved asset-local transform. | | [Place content using geo coordinates](#place-content-using-geo-coordinates) | Content is authored at a known latitude, longitude, and altitude. | Geolocation data such as latitude, longitude, altitude, and optional heading. | Reconstructs placement from geospatial coordinates and reapplies the resulting pose. | ## Know which readiness signal your approach uses The two approaches read **different** readiness signals, and using the wrong one is a common source of misplaced content: | Approach | Check this before placing | Why | | --- | --- | --- | | Place content relative to a localized asset | The **asset's** tracking data | The content is attached to the asset's origin, so the asset's pose is what has to be reliable. | | Place content using geo coordinates | The **device's** `Vps2TrackingState` | The content's position comes from the device's localization, not from a tracked asset. | These two states change independently -- the VPS2 tracking state can be `precise` while an asset is not yet returning tracking data. See [Tracking states](https://www.nianticspatial.com/docs/nsdk/features/vps2/#tracking-states). --- ### Place content relative to a localized asset Use this approach when the user decides where content should appear during a localized session. Save the content's local transform relative to the asset's origin, then apply that same local transform again the next time the asset is tracked. When you check an asset's tracking data, follow these rules: | Rule | Why it matters | What to do | |---|---|---| | **Evaluate each asset separately.** | If you localize to more than one Site, each asset is tracked on its own. Do not combine them into a single session-wide flag, or a distant asset that is not tracked will misreport a nearby asset that is. | Store and evaluate readiness by asset ID. | | **Tracking data means a pose is available; its confidence says how good it is.** | When VPS2 returns asset tracking data, the origin pose is available to place content against. The tracking confidence ( ### Platform: swift`confidence`### Platform: kotlin`trackingConfidence`### Platform: unity`Confidence`, `0.0`-`1.0`) estimates how likely the pose is to be correct. It rises as localizations agree, and falls as they disagree or grow stale while the device moves. | Place content once tracking data is available. If your content must align closely with the real world, also require a minimum tracking confidence, tuned by testing at your Site. | | **Check again after AR tracking is disturbed.** | If the camera is covered, the device is moved abruptly, or the session is interrupted, the asset can stop returning tracking data while content stays on screen at an out-of-date position. | Hide the placed content while tracking data is unavailable, and show it again once it returns. | ### Platform: unity Localize to a Site with [`TryLocalize`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager.TryLocalize) on [`ARVps2Manager`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager), track the localized asset with [`TryTrackAsset`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager.TryTrackAsset), and place content under the returned [`ARVps2Asset`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Asset) trackable, whose transform follows the asset's origin in Unity world space. Follow this workflow to place, save, and later restore content relative to a localized asset: 1. Localize to a Site with one of the `TryLocalize` overloads. Localization is asynchronous; VPS2 reports the localized asset through [`TryGetLatestLocalization`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager.TryGetLatestLocalization) once the device matches a Site's VPS map. - To localize to a specific Site, [create a Site](https://www.nianticspatial.com/docs/nsdk/first_localization/#create-a-private-site) in [Scaniverse](https://scaniverse.nianticspatial.com/signin) and get its Site ID (from Scaniverse or the [Sites API](https://www.nianticspatial.com/docs/nsdk/how-to/sites/getting_started/#3-query-sites)), then call [`TryLocalize(siteId)`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager.TryLocalize). - To localize to nearby Sites, call [`TryLocalize(latitude, longitude, radiusMeters)`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager.TryLocalize) with the device's location. You do not need a Site ID. While the device localizes, [guide the user toward a successful localization](https://www.nianticspatial.com/docs/nsdk/how-to/vps2/guide_users_during_localization/), for example by prompting them to hold steady, improve the camera angle, or check their connection. 2. Track the localized asset with [`TryTrackAsset`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager.TryTrackAsset), using the asset ID that `TryGetLatestLocalization` reports. Keep the returned [`ARVps2Asset`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Asset), because your content will be attached relative to it. This code is introduced in step 1 of the following runnable example. 3. Wait until the asset reaches `Tracking`. This prevents you from placing content against an unsettled asset pose: the trackable starts at the scene origin and snaps to the asset's origin when tracking data first arrives. An asset whose tracking lapses degrades to `Limited` and keeps its last known pose, so also hide placed content while the asset is not `Tracking`. This check is introduced in step 1 of the following runnable example. 4. When the user chooses a placement pose, instantiate the content and parent it under `asset.transform`. Parenting the content under the tracked asset keeps it aligned as VPS2 refines the asset's origin. This code is introduced in step 1 of the following runnable example. 5. Save the Site ID and asset ID together with the content's `localPosition` and `localRotation`. Those values are what let you restore the same content relative to the same asset later. This code is introduced in step 1 of the following runnable example. Expand the following example if you want a runnable Unity component that implements this workflow end to end, including localizing to the Site, tracking its asset, placing content under it, and saving the asset-relative pose for later restoration. #### Runnable example: Place content relative to a localized asset The code comments in this example refer to the workflow steps in the previous list. To wire this example into a runnable scene, do the following: This example already includes a small set of `Debug.Log` and `Debug.LogError` statements that you can use during testing. Open the Unity Console while the scene is running to confirm that localization starts, the placement succeeds, and the pose is saved. To keep this example runnable without extra UI, paste a known Site ID into the `Site Id` field on the `PlaceContentAtLocalizedAsset` component in the Unity Inspector. Get the ID by navigating to the Site in the Scaniverse portal and copying it from the URL, or from the [Sites API](https://www.nianticspatial.com/docs/nsdk/how-to/sites/getting_started/#3-query-sites). 1. Create a new Unity script file named `PlaceContentAtLocalizedAsset.cs`, then replace its contents with the following code: The following example defines the component that localizes to a Site, tracks its localized asset, places content under that asset, and saves the asset-relative pose: ```cs using System; using NianticSpatial.NSDK.AR; using NianticSpatial.NSDK.AR.VPS2; using UnityEngine; using UnityEngine.UI; using UnityEngine.XR.ARSubsystems; [Serializable] public struct SavedAssetRelativeContent { public string siteId; public string assetId; public Vector3 localPosition; public Quaternion localRotation; } public class PlaceContentAtLocalizedAsset : MonoBehaviour { [SerializeField] private ARVps2Manager _arVps2Manager; [SerializeField] private string _siteId; [SerializeField] private GameObject _contentPrefab; [SerializeField] private Button _placeButton; [SerializeField] private Camera _arCamera; [SerializeField, Range(0f, 1f)] private float _minimumTrackingConfidence = 0f; private ARVps2Asset _asset; private void OnEnable() { _placeButton.onClick.AddListener(OnPlaceButtonClicked); // Workflow step 1: request localization to the Site by its Site ID. if (!string.IsNullOrWhiteSpace(_siteId) && !_arVps2Manager.TryLocalize(_siteId)) { Debug.LogError("PlaceContentAtLocalizedAsset: TryLocalize failed."); } } private void OnDisable() { _placeButton.onClick.RemoveListener(OnPlaceButtonClicked); } private void Update() { if (_asset != null) { return; } // Workflow step 2: once VPS2 reports the localized asset, take its trackable. The // asset manager already tracks the localized asset, so TryTrackAsset returns the // existing trackable, whose transform follows the asset's origin in Unity world space. if (_arVps2Manager.TryGetLatestLocalization(out var localization) && localization.LocalizedAsset.HasValue) { _arVps2Manager.TryTrackAsset(localization.LocalizedAsset.Value.AssetId, out _asset); } } private void OnPlaceButtonClicked() { // Workflow step 3: wait until the asset's origin is tracked before placing content. if (_asset == null || _asset.trackingState != TrackingState.Tracking || _asset.Confidence < _minimumTrackingConfidence) { return; } // Workflow step 4: place the content in world space, then parent it under the asset. var worldPosition = _arCamera.transform.position + _arCamera.transform.forward; var worldRotation = Quaternion.LookRotation(_arCamera.transform.forward, Vector3.up); var content = Instantiate(_contentPrefab, worldPosition, worldRotation); content.transform.SetParent(_asset.transform, true); // Workflow step 5: save the Site ID and asset ID plus the content's asset-local pose. var savedPose = new SavedAssetRelativeContent { siteId = _asset.SiteId, assetId = _asset.AssetId, localPosition = content.transform.localPosition, localRotation = content.transform.localRotation }; SavePose(savedPose); } private void SavePose(SavedAssetRelativeContent savedPose) { Debug.Log("PlaceContentAtLocalizedAsset: content placed and pose saved."); // Store the Site ID, asset ID, and local pose in PlayerPrefs, a file, or your backend. } } ``` 2. Create a clean test scene for this example. In a fresh Unity scene, add the following objects from the `Hierarchy` menu: - in the `Hierarchy` context menu, choose `XR > AR Session` - in the `Hierarchy` context menu, choose `XR > XR Origin (Mobile AR)` - create an empty `GameObject`, name it `AR Input Manager`, then use `Add Component` to add `AR Input Manager` - in the `Hierarchy` context menu, choose `UI > Canvas` - under `Canvas`, choose `UI > Button` - an empty `GameObject` named `PlacedContentManager` Then: - add [`ARVps2Manager`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager) to `XR Origin`. Unity also adds `ARVps2AnchorManager` and `ARVps2AssetManager` automatically; the asset manager serves the `ARVps2Asset` trackables this example places content under. - add `PlaceContentAtLocalizedAsset` to `PlacedContentManager` - add your new scene to `Build Profiles > Scenes` and move it ahead of `Home` so the app opens directly into it on device 3. Select the `GameObject` that has the `PlaceContentAtLocalizedAsset` component, then assign the serialized fields in the Inspector: - Drag the object that has [`ARVps2Manager`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager) into the `ARVps2Manager` field. In the `nsdk-samples-csharp` project, drag `XR Origin` into the `ARVps2Manager` field. - Paste the Site ID into the `Site Id` field. - Drag your AR camera into the camera field. In the `nsdk-samples-csharp` project, drag `Main Camera` from `XR Origin > Camera Offset > Main Camera` into the camera field. - Create or choose the UI button that should trigger placement, then drag it into the button field. - Drag the prefab asset you want to place into the content field. For a fast visible test object, create `Hierarchy > 3D Object > Cube`, then drag that `Cube` into your project to create a prefab asset. - Set `Minimum Tracking Confidence` to the threshold required by your experience. The default `0` demonstrates the minimum tracked-state check; increase it when content must align precisely with a real-world surface. 4. Run the scene and wait for the asset to reach `Tracking`. `OnEnable()` calls `TryLocalize(_siteId)` automatically when the `Site Id` field is not empty, and `Update()` tracks the localized asset as soon as VPS2 reports it. View the asset status in your device logs by logging `_asset.trackingState` while testing. Wait until the asset status reaches `Tracking` before pressing the button you created in step 3. The on-screen validation order for this runnable example is: - launch the scene on device - wait for the localized asset to reach `Tracking` - press the placement button once Build to iPhone or Android to validate asset-relative placement at a real Site. 6. Restore the content in a later session. Localize to the same Site, track the same asset by its saved ID, then reapply the saved local transform so the content returns to the same real-world location. This code is introduced in steps 1 through 6 of the following runnable example. Expand the following example if you want a runnable Unity restore component for the same workflow. Test it in a second run after you already placed content once and captured the saved Site ID, asset ID, and local transform values from step 5. #### Runnable example: Restore content relative to a localized asset The code in this example implements the restore phase from step 6 of the previous list. To wire it into the same scene and test it, do the following: 1. Create a new Unity script file named `ContentRestore.cs`, then replace its contents with the following code: The following example defines the component that localizes to the saved Site again, tracks the saved asset, and reapplies the saved local pose when tracking is stable. This script reuses the `SavedAssetRelativeContent` struct that you already created in `PlaceContentAtLocalizedAsset.cs` in step 5: ```cs using NianticSpatial.NSDK.AR; using NianticSpatial.NSDK.AR.VPS2; using UnityEngine; using UnityEngine.XR.ARSubsystems; public class ContentRestore : MonoBehaviour { [SerializeField] private ARVps2Manager _arVps2Manager; [SerializeField] private GameObject _contentPrefab; [SerializeField] private SavedAssetRelativeContent _savedContent; [SerializeField, Range(0f, 1f)] private float _minimumTrackingConfidence = 0f; private ARVps2Asset _asset; private bool _contentRestored; public bool BeginRestore() { var started = _arVps2Manager.TryLocalize(_savedContent.siteId); Debug.Log($"ContentRestore: BeginRestore started = {started}"); return started; } private void Update() { if (_contentRestored) { return; } // The saved asset can be tracked as soon as its Site's localize request has // been submitted; the trackable snaps to the asset's origin once tracking // data arrives. if (_asset == null && !_arVps2Manager.TryTrackAsset(_savedContent.assetId, out _asset)) { return; } if (_asset.trackingState != TrackingState.Tracking || _asset.Confidence < _minimumTrackingConfidence) { return; } var content = Instantiate(_contentPrefab, _asset.transform); content.transform.localPosition = _savedContent.localPosition; content.transform.localRotation = _savedContent.localRotation; _contentRestored = true; Debug.Log("ContentRestore: content restored under the tracked asset."); } } ``` 2. Add `ContentRestore` to the same scene object that already stays active while VPS2 localization is running. In a fresh Unity scene, add it to the same `PlacedContentManager` `GameObject` that already holds `PlaceContentAtLocalizedAsset`. In the `nsdk-samples-csharp` project, the closest reference point is the `VPS2LocalizeDemo` object in `NsdkSamples/Assets/Samples/VPS2/Scenes/VPS2Localization.unity`. 3. Select the `GameObject` that has the `ContentRestore` component, then assign the serialized fields in the Inspector: - Drag the object that has [`ARVps2Manager`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager) into the `ARVps2Manager` field. In the `nsdk-samples-csharp` project, drag `XR Origin` into the `ARVps2Manager` field. - Drag the same prefab asset you used during placement into the content field. In the `nsdk-samples-csharp` project, use the same prefab you assigned to `PlaceContentAtLocalizedAsset`. - Enter the saved `siteId`, `assetId`, `localPosition`, and `localRotation` into the `Saved Content` field. For a real restore test, use the values you captured from the successful placement run in step 5. - Use the same `Minimum Tracking Confidence` threshold as the placement flow. 4. Add a small helper script that starts the restore flow when the scene loads. Create a new Unity script file named `RestoreStarter.cs`, then place it on the same `PlacedContentManager` object as `ContentRestore`. The following script example starts the restore flow automatically when the scene loads: ```cs using UnityEngine; public class RestoreStarter : MonoBehaviour { [SerializeField] private ContentRestore _contentRestore; private void Start() { _contentRestore.BeginRestore(); } } ``` 5. Select the `GameObject` that has the `RestoreStarter` component, then drag the `ContentRestore` component into the `Content Restore` field. For the restore run, disable `PlaceContentAtLocalizedAsset` so the scene does not place more content when it starts. 6. Build to iPhone or Android and run the scene again at the same Site. Do not press the placement button in this run. Wait for the asset to reach `Tracking`. The on-screen validation order for this runnable example is: - launch the restore scene on device - wait for the saved asset to reach `Tracking` - do not press any placement button in this run, because `RestoreStarter` begins restore automatically The expected result is that the content is recreated automatically under the tracked asset and returns to the same real-world location. > **Note:** > > **Legacy: anchor-based placement** > > Anchors remain supported: track a Site anchor from its payload with [`TryTrackAnchor`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager.TryTrackAnchor) and place content relative to the returned [`ARVps2Anchor`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Anchor), served by [`ARVps2AnchorManager`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2AnchorManager). The `VPS2Localization` sample scene keeps this flow in the disabled-by-default `VPS2AnchorLocalizeDemo` component. [`TryCreateAnchor`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager.TryCreateAnchor) additionally creates an anchor at an arbitrary pose, for example far away from the Site. New apps should prefer the localized-asset flow above. ### Platform: swift Follow this workflow to place, save, and later restore content relative to a localized asset: 1. Localize to a Site with one of the `localize` overloads. - To localize to a specific Site, [create a Site](https://www.nianticspatial.com/docs/nsdk/first_localization/#create-a-private-site) in [Scaniverse](https://scaniverse.nianticspatial.com/signin) and get its Site ID (from Scaniverse or the [Sites API](https://www.nianticspatial.com/docs/nsdk/how-to/sites/getting_started/#3-query-sites)), then call [`localize(siteId:)`](https://www.nianticspatial.com/docs/api/swift/NSDK.NSDKVps2Session.method-localize). - To localize to nearby Sites, call [`localize(latitude:longitude:radiusMeters:)`](https://www.nianticspatial.com/docs/api/swift/NSDK.NSDKVps2Session.method-localize) with the device's location. You do not need a Site ID. 2. Poll [`assetTrackingData()`](https://www.nianticspatial.com/docs/api/swift/NSDK.NSDKVps2Session.method-assetTrackingData) for the localized asset and keep a matching `AnchorEntity` updated. `assetTrackingData()` returns data only while the asset is tracked; check its `confidence` if your content must align closely with the real world. When it is available, apply its `assetToTrackingTransform` to the `AnchorEntity` so content stays aligned as VPS2 refines the asset's pose. 3. Parent content under the asset's `AnchorEntity` using an asset-local `Transform`. Save that local transform together with the Site ID and asset ID. 4. Restore in a later session by localizing to the same Site again, waiting for tracking data to become available, then reapplying the saved local `Transform` under the asset's `AnchorEntity`. The following runnable example shows one complete class with localization, asset-pose updates, placement, save, and restore: #### Runnable example: Place and restore content relative to a localized asset Create a new file named `SiteAssetContentController.swift` and add the following code: ```swift import Combine import NSDK import RealityKit import simd import UIKit struct SavedAssetRelativeContent { let siteId: String let assetId: String let localTransform: Transform } /// Core VPS2 asset-relative placement flow. /// Trigger `startLocalizing`, `placeCubeAtAsset`, and `restoreSavedContent` /// from your own UI actions or app lifecycle code. final class SiteAssetContentController { private let vps2Session: NSDKVps2Session private weak var scene: Scene? private var siteId: String? private var currentAssetId: String? private let assetAnchor = AnchorEntity(world: .zero) private var cancellables = Set() private var assetReady = false private(set) var savedContent: SavedAssetRelativeContent? init(vps2Session: NSDKVps2Session, scene: Scene) { self.vps2Session = vps2Session self.scene = scene scene.addAnchor(assetAnchor) } func startLocalizing(siteId: String) throws { self.siteId = siteId try vps2Session.localize(siteId: siteId) } /// `$latestLocalization` fires every frame, so it doubles as the polling clock for the /// asset's pose. func beginAssetUpdates() { vps2Session.$latestLocalization .sink { [weak self] _ in self?.updateAssetPose() } .store(in: &cancellables) } private func updateAssetPose() { // Poll the currently localized asset. Data is served only while it is tracked. guard let tracking = vps2Session.assetTrackingData() else { assetReady = false assetAnchor.isEnabled = false // hide placed content while the asset is not tracked return } currentAssetId = tracking.assetId assetAnchor.transform = Transform(matrix: tracking.assetToTrackingTransform) assetAnchor.isEnabled = true assetReady = true } /// Call this from your placement UI action once the asset is tracked. func placeCubeAtAsset() { guard assetReady, let siteId, let currentAssetId else { return } let content = makeCubeEntity(size: 0.15, color: .green) let localTransform = Transform( scale: .one, rotation: simd_quatf(), translation: SIMD3(0, 0, -0.5) ) content.transform = localTransform assetAnchor.addChild(content) savedContent = SavedAssetRelativeContent(siteId: siteId, assetId: currentAssetId, localTransform: localTransform) } /// Call this in a later session after localizing to the same Site again. func restoreSavedContent() { // Restore only once the saved asset is tracked again. guard let savedContent, vps2Session.assetTrackingData(assetId: savedContent.assetId) != nil else { return } let restored = makeCubeEntity(size: 0.15, color: .yellow) restored.transform = savedContent.localTransform assetAnchor.addChild(restored) } private func makeCubeEntity(size: Float, color: UIColor) -> ModelEntity { ModelEntity( mesh: .generateBox(size: size), materials: [SimpleMaterial(color: color, isMetallic: false)] ) } } ``` Expected result: after you save a local `Transform` and localize to the same Site again, restored content returns to the same real-world location. ### Platform: kotlin Follow this workflow to place, save, and later restore content relative to a localized asset: 1. Localize to a Site with one of the `localize` overloads. Localization is asynchronous; VPS2 reports the localized asset on `localizationUpdates` once the device matches a Site's VPS map. - To localize to a specific Site, [create a Site](https://www.nianticspatial.com/docs/nsdk/first_localization/#create-a-private-site) in [Scaniverse](https://scaniverse.nianticspatial.com/signin) and get its Site ID (from Scaniverse or the [Sites API](https://www.nianticspatial.com/docs/nsdk/how-to/sites/getting_started/#3-query-sites)), then call [`localize(siteId)`](https://www.nianticspatial.com/docs/api/kotlin/com.nianticspatial.nsdk.vps2.Vps2Session.localize). - To localize to nearby Sites, call [`localize(latitude, longitude, radiusMeters)`](https://www.nianticspatial.com/docs/api/kotlin/com.nianticspatial.nsdk.vps2.Vps2Session.localize) with the device's location. You do not need a Site ID. 2. Create a helper that owns a `PoseNode` for the localized asset and applies the latest asset transform to it. The following code example shows the helper type that manages the localized asset's `PoseNode`: ```kotlin class AssetRelativeContentPlacement( private val engine: Engine, private val arChildNodes: MutableList, ) { private var poseNode: PoseNode? = null } ``` 3. Collect `localizationUpdates` and, on each emission, poll `getAssetTrackingData(assetId)` for the localized asset. When it returns data, fill a column-major `FloatArray(16)` with `tracking.assetToLocalTrackingTransform.toMatrix(matrix, 0)` and apply it to the `PoseNode`. `getAssetTrackingData` returns data only while the asset is tracked, so the `PoseNode` updates only while a pose is available. Check its `trackingConfidence` if your content must align closely with the real world. The following code does that: ```kotlin vps2Session.localizationUpdates .onEach { localization -> val assetId = localization.localizedAsset?.assetId val tracking = assetId?.let { vps2Session.getAssetTrackingData(it) } if (tracking == null) { placement.setContentVisible(false) // hide while the asset is not tracked return@onEach } val matrix = FloatArray(16) tracking.assetToLocalTrackingTransform.toMatrix(matrix, 0) placement.applyAssetMatrix(matrix) placement.setContentVisible(true) } .launchIn(scope) ``` 4. Add your content as a child node under that `PoseNode`. Adding the content under the `PoseNode` keeps it aligned as VPS2 refines the asset's pose. ```kotlin val poseNode = placement.getOrCreatePoseNode() val contentNode = CubeNode( engine = engine, size = Float3(0.2f, 0.2f, 0.2f), center = Float3(0f, 0f, 0f), materialInstance = refinedGreenMaterial, ) poseNode.addChildNode(contentNode) ``` 5. Persist the Site ID, asset ID, and the child node's local pose so you can restore it later. ```kotlin savedSiteId = siteId savedAssetId = assetId savedLocalPosition = contentNode.position savedLocalRotation = contentNode.rotation ``` Expand the following example for a runnable Kotlin controller that covers this workflow -- the helper file plus the wiring to place and restore content in the sample app: #### Runnable example: Place and restore content relative to a localized asset 1. Create a new file named `AssetRelativeContentPlacement.kt`. If you are using the sample app, create it in `kotlin/NsdkSamples/src/main/java/com/nianticspatial/nsdk/externalsamples/vps2/` next to `VPS2View.kt`, `VPS2Manager.kt`, and `VPS2Route.kt`. The following defines the controller that follows the localized asset and the helper that keeps its `PoseNode` updated: ```kotlin package com.nianticspatial.nsdk.externalsamples.vps2 import com.nianticspatial.nsdk.NSDKSession import com.nianticspatial.nsdk.vps2.Vps2Session import io.github.sceneview.node.Node import dev.romainguy.kotlin.math.Float4 import dev.romainguy.kotlin.math.Mat4 import io.github.sceneview.ar.node.PoseNode import com.google.android.filament.Engine import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach class AssetRelativeContentPlacementController( nsdkSession: NSDKSession, engine: Engine, arChildNodes: MutableList, private val scope: CoroutineScope, ) { private val vps2Session: Vps2Session = nsdkSession.vps2.acquire() val placement = AssetRelativeContentPlacement(engine, arChildNodes) // The ID of the currently tracked asset, saved alongside the placement. var currentAssetId: String? = null private set // getAssetTrackingData returns data only while the asset is tracked, so the // PoseNode updates only while a pose is available. fun startTrackingLocalizedAsset(): Job { return vps2Session.localizationUpdates .onEach { localization -> val assetId = localization.localizedAsset?.assetId val tracking = assetId?.let { vps2Session.getAssetTrackingData(it) } if (assetId == null || tracking == null) { placement.setContentVisible(false) // hide while the asset is not tracked return@onEach } currentAssetId = assetId val matrix = FloatArray(16) tracking.assetToLocalTrackingTransform.toMatrix(matrix, 0) placement.applyAssetMatrix(matrix) placement.setContentVisible(true) } .launchIn(scope) } fun isAssetTracked(assetId: String): Boolean = vps2Session.getAssetTrackingData(assetId) != null } class AssetRelativeContentPlacement( private val engine: Engine, private val arChildNodes: MutableList, ) { private var poseNode: PoseNode? = null fun getOrCreatePoseNode(): PoseNode { return poseNode ?: PoseNode(engine = engine).also { poseNode = it arChildNodes.add(it) } } fun setContentVisible(visible: Boolean) { poseNode?.isVisible = visible } fun applyAssetMatrix(matrix: FloatArray) { getOrCreatePoseNode().worldTransform = Mat4( Float4(matrix[0], matrix[1], matrix[2], matrix[3]), Float4(matrix[4], matrix[5], matrix[6], matrix[7]), Float4(matrix[8], matrix[9], matrix[10], matrix[11]), Float4(matrix[12], matrix[13], matrix[14], matrix[15]), ) } } ``` 2. In your VPS2 screen, after localizing to the Site, create the controller and start tracking the localized asset. Hold the saved placement state in remembered state so both the place and restore actions can read it: ```kotlin import io.github.sceneview.math.Position import io.github.sceneview.math.Rotation import androidx.compose.runtime.rememberCoroutineScope val coroutineScope = rememberCoroutineScope() var savedSiteId by remember { mutableStateOf(null) } var savedAssetId by remember { mutableStateOf(null) } var savedLocalPosition by remember { mutableStateOf(null) } var savedLocalRotation by remember { mutableStateOf(null) } val controller = remember { AssetRelativeContentPlacementController( nsdkSession = nsdkManager.session, engine = engine, arChildNodes = arChildNodes, scope = coroutineScope, ).also { it.startTrackingLocalizedAsset() } } ``` 3. Add a `Place Content` button that attaches content under the asset's `PoseNode` and saves the Site ID plus the content's local pose: ```kotlin Button(onClick = { val poseNode = controller.placement.getOrCreatePoseNode() val contentNode = CubeNode( engine = engine, size = Float3(0.2f, 0.2f, 0.2f), center = Float3(0f, 0f, 0f), materialInstance = refinedGreenMaterial, ) poseNode.addChildNode(contentNode) savedSiteId = siteId savedAssetId = controller.currentAssetId savedLocalPosition = contentNode.position savedLocalRotation = contentNode.rotation }) { Text("Place Content") } ``` 4. To restore in a later session, localize to the same Site again, wait for the asset's `PoseNode` to update, then add a `Restore Content` button that reattaches a node with the saved local pose: ```kotlin Button(onClick = { val assetId = savedAssetId ?: return@Button val localPosition = savedLocalPosition ?: return@Button val localRotation = savedLocalRotation ?: return@Button if (!controller.isAssetTracked(assetId)) return@Button // wait until the saved asset is tracked again val poseNode = controller.placement.getOrCreatePoseNode() val restoredNode = CubeNode( engine = engine, size = Float3(0.2f, 0.2f, 0.2f), center = Float3(0f, 0f, 0f), materialInstance = refinedGreenMaterial, ).apply { position = localPosition rotation = localRotation } poseNode.addChildNode(restoredNode) }) { Text("Restore Content") } ``` 5. Build to Android and validate on a real device. The on-screen validation order is: - open `VPS2` - localize to the Site and wait until the asset's cube appears - tap `Place Content` - move the device, then tap `Restore Content` If you want to compare this explanation against a working implementation, review `VPS2Manager.kt` -- see `pollAssetTrackingData` and `updateAssetMesh` -- and `VPS2View.kt` in [nsdk-samples-kotlin](https://github.com/nianticspatial/nsdk-samples-kotlin). Expected result: after you save local pose data and localize to the same Site again, the content returns to the same real-world location. ## Place content using geo coordinates When you place content from geo coordinates, follow these rules: | Rule | Why it matters | What to do | |---|---|---| | **At a Site, wait for the precise state before placing.** | ### Platform: unity`TryGetPose`### Platform: swift, kotlin`getPose` returns a pose even in the `coarse` tracking state, but it is only accurate enough to anchor virtual content once the state is `precise`. The state can fall back to `coarse`, for example when AR tracking resets. | Gate content placement on the `precise` state, keep checking it on each update, and treat `coarse` poses as approximate. | | **Expect `coarse` away from mapped Sites.** | Only localizing to a VPS map reaches `precise`. Local sensor fusion and universal localization stop at `coarse`, so content gated on `precise` does not appear away from mapped Sites. The runnable examples below gate on `precise`. | For content outside mapped Sites, place it in the `coarse` state and size or fade it using the reported horizontal and heading accuracy. | ### Platform: unity Follow this workflow to place content from geo coordinates: 1. Get the known latitude, longitude, and altitude for the real-world location where the content should appear. For example, these values might come from authored content data, a backend, or a fixed test location. For quick testing, use a known nearby coordinate from a maps app. NSDK expects altitude in meters above the WGS84 ellipsoid; elevations shown by maps apps are usually above mean sea level, which can differ by tens of meters. 2. On each update, call [`TryGetPose`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager.TryGetPose) with the target latitude, longitude, and altitude, and an EDN orientation quaternion (often [`Quaternion.identity`](https://docs.unity3d.com/ScriptReference/Quaternion-identity.html) when the target has no specific heading). `TryGetPose` uses the latest localization and returns `false` until VPS2 has localized. The returned `XRVps2Pose` carries its `TrackingState`; when your content needs accurate placement, wait for the `Precise` state. This code is introduced in step 1 of the following runnable example. 3. Apply the returned pose to your `GameObject` and keep updating it as VPS2 improves. Repeating this conversion lets the content stay aligned to the latest VPS2 estimate. This code is introduced in step 1 of the following runnable example. The following example converts a fixed geolocation into an AR pose and applies that pose to a Unity `GameObject` as VPS2 improves: #### Runnable example: Place content from geo coordinates The code comments in this example refer to the workflow steps in the previous list. To wire this example into a runnable scene and test it, do the following: 1. Create a new Unity script file named `GeoPositionedObjectHelper.cs`. In the `nsdk-samples-csharp` project, place the script in `NsdkSamples/Assets/Samples/VPS2/Scripts`. The following example defines the component that converts a fixed geolocation into a `GameObject` pose. It includes a small `Debug.Log` you can watch on device to confirm that the helper is applying updated poses: ```cs using NianticSpatial.NSDK.AR; using NianticSpatial.NSDK.AR.VPS2; using NianticSpatial.NSDK.AR.XRSubsystems; using UnityEngine; public class GeoPositionedObjectHelper : MonoBehaviour { [SerializeField] private ARVps2Manager _vps2Manager; [SerializeField] private double _latitude; [SerializeField] private double _longitude; [SerializeField] private double _altitude; void Update() { // Workflow step 2: convert the target geolocation into an AR pose. TryGetPose uses // the latest localization and returns false until VPS2 has localized. if (!_vps2Manager.TryGetPose( _latitude, _longitude, _altitude, Quaternion.identity, out var pose)) { return; } // This content needs accurate placement, so wait for the precise tracking state. if (pose.TrackingState != Vps2TrackingState.Precise) { return; } // Workflow step 3: apply the latest pose to the GameObject. gameObject.transform.SetPositionAndRotation( pose.Pose.position, pose.Pose.rotation); Debug.Log("GeoPositionedObjectHelper: applied latest pose to GameObject."); } } ``` 2. Create a clean test scene for this example. In a fresh Unity scene, add the following objects from the `Hierarchy` menu: - in the `Hierarchy` context menu, choose `XR > AR Session` - in the `Hierarchy` context menu, choose `XR > XR Origin (Mobile AR)` - create an empty `GameObject`, name it `AR Input Manager`, then use `Add Component` to add `AR Input Manager` - in the `Hierarchy` context menu, choose `3D Object > Cube`, then rename it `GeoContent` Then: - add [`ARVps2Manager`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager) to `XR Origin` - add `GeoPositionedObjectHelper` to `GeoContent` Add your new scene to `Build Profiles > Scenes` and move it ahead of `Home` so the app opens directly into it on device. 3. Select the `GameObject` that has the `GeoPositionedObjectHelper` component, then assign the serialized fields in the Inspector: - Drag the object that has [`ARVps2Manager`](https://www.nianticspatial.com/docs/api/unity/NianticSpatial.NSDK.AR.VPS2.ARVps2Manager) into the `ARVps2Manager` field. In the `nsdk-samples-csharp` project, drag `XR Origin` into the `ARVps2Manager` field. - Enter the target latitude in the `Latitude` field. In the `nsdk-samples-csharp` project, enter the latitude for the real-world location you want to test. - Enter the target longitude in the `Longitude` field. In the `nsdk-samples-csharp` project, enter the longitude for the same real-world location. - Enter the target altitude in the `Altitude` field. In the `nsdk-samples-csharp` project, enter the altitude for that same location. If the latitude and longitude are correct but `GeoContent` is not visible, the altitude may be placing it too high or too low. Start with a large visible cube and adjust altitude until it enters view. 4. Build to iPhone or Android and run the scene while VPS2 is localizing. This helper does not start VPS2 by itself. It only reads the latest localization after `ARVps2Manager` has started. The on-screen validation order for this runnable example is: - launch the scene on device - let VPS2 localize normally - do not press a placement button, because this helper updates automatically during `Update()` Watch the device logs for: The following text example shows the log message that confirms the helper applied an updated pose: ```text GeoPositionedObjectHelper: applied latest pose to GameObject. ``` The expected result is that the visible `GeoContent` cube moves automatically into the AR pose for that real-world location and stays aligned as localization improves. Unlike the asset-relative example, this flow does not use a placement button. ### Platform: swift Follow this workflow to place content from geo coordinates: 1. Define the target latitude, longitude, and altitude for the real-world location where content should appear. These values can come from authored data, backend content, or a known location. 2. Pass those coordinates to the placement feature that already owns your VPS2 session and RealityKit scene objects. 3. On each localization update, convert the target latitude, longitude, and altitude to an AR pose with [`getPose(location:)`](https://www.nianticspatial.com/docs/api/swift/NSDK.NSDKVps2Session.method-getPose) and apply its transform to your RealityKit entity. `getPose(location:)` uses the latest localization and returns `nil` until VPS2 has localized. The returned `Vps2Pose` carries its `trackingState`; when your content needs accurate placement, wait for the `.precise` state. 4. Repeat step 3 every frame to keep content aligned as localization improves. The following runnable example shows one complete class with placement updates: #### Runnable example: Place and restore content from geo coordinates Create a new file named `GeoCoordinateContentController.swift` and add the following code: ```swift import Combine import NSDK import RealityKit struct SavedGeoPlacement { let latitude: Double let longitude: Double let altitude: Double } /// Core VPS2 geo-coordinate placement flow. /// Trigger `setTargetLocation` and `restoreSavedPlacement` /// from your own UI actions or app lifecycle code. final class GeoCoordinateContentController { private let vps2Session: NSDKVps2Session private weak var scene: Scene? private let contentRootAnchor = AnchorEntity(world: .zero) private let contentEntity: ModelEntity private var targetLocation: GeolocationData? private var cancellables = Set() private(set) var savedPlacement: SavedGeoPlacement? init(vps2Session: NSDKVps2Session, scene: Scene) { self.vps2Session = vps2Session self.scene = scene self.contentEntity = ModelEntity( mesh: .generateBox(size: 0.75), materials: [SimpleMaterial(color: .yellow, isMetallic: false)] ) contentRootAnchor.addChild(contentEntity) scene.addAnchor(contentRootAnchor) } func setTargetLocation(latitude: Double, longitude: Double, altitude: Double) { targetLocation = GeolocationData( latitude: latitude, longitude: longitude, altitude: altitude ) savePlacement(latitude: latitude, longitude: longitude, altitude: altitude) updateContentPose() } /// Call this after VPS2 starts to keep geo content updated. func beginLocalizationUpdates() { vps2Session.$latestLocalization .sink { [weak self] _ in self?.updateContentPose() } .store(in: &cancellables) } func updateContentPose() { guard let targetLocation else { return } // getPose returns a pose even in the coarse tracking state, but it is only accurate // enough to place content once the pose's tracking state is precise. guard let pose = vps2Session.getPose(location: targetLocation), pose.trackingState == .precise else { return } contentEntity.transform = Transform(matrix: pose.transform) } private func savePlacement(latitude: Double, longitude: Double, altitude: Double) { savedPlacement = SavedGeoPlacement( latitude: latitude, longitude: longitude, altitude: altitude ) } /// Call this in a later run after your app has started VPS2 again. func restoreSavedPlacement() { guard let savedPlacement else { return } setTargetLocation( latitude: savedPlacement.latitude, longitude: savedPlacement.longitude, altitude: savedPlacement.altitude ) } } ``` For reading the device's geolocation, see [Use geoposition with VPS2](https://www.nianticspatial.com/docs/nsdk/how-to/vps2/getting_vps2_geoposition/). ### Platform: kotlin Follow this workflow to place content from geo coordinates: 1. Define the target latitude, longitude, and altitude for the real-world location where content should appear. These values can come from authored data, backend content, or a known location. 2. Pass those coordinates to a helper that owns your VPS2 session. The helper builds a [`GeolocationData`](https://www.nianticspatial.com/docs/api/kotlin/com.nianticspatial.nsdk.GeolocationData) from them. 3. On each localization update, convert that `GeolocationData` to an AR pose with [`getPose`](https://www.nianticspatial.com/docs/api/kotlin/com.nianticspatial.nsdk.vps2.Vps2Session.getPose) and apply its pose to your content. `getPose` uses the latest localization and returns `null` until VPS2 has localized. The returned `Vps2Pose` carries its `trackingState`; when your content needs accurate placement, wait for the `PRECISE` state, then apply `pose.pose`. 4. Repeat step 3 on every localization update to keep content aligned as localization improves. The following runnable example shows one complete helper that converts a fixed geolocation into an AR pose: #### Runnable example: Place content from geo coordinates Create a new file named `GeoPositionedObjectHelper.kt` and add the following code: ```kotlin import com.google.ar.core.Pose import com.nianticspatial.nsdk.GeolocationData import com.nianticspatial.nsdk.vps2.Vps2Session import com.nianticspatial.nsdk.vps2.Vps2TrackingState class GeoPositionedObjectHelper( private val vps2Session: Vps2Session, latitude: Double, longitude: Double, altitude: Double, ) { private val targetLocation = GeolocationData( latitude = latitude, longitude = longitude, altitude = altitude, heading = 0.0, orientationEdn = floatArrayOf(0f, 0f, 0f, 1f), ) fun updateContentPose(onPoseUpdated: (Pose) -> Unit) { // getPose uses the latest localization and returns null until VPS2 has localized. // The returned Vps2Pose carries the tracking state it was computed from. val pose = vps2Session.getPose(targetLocation) ?: return // The pose is only accurate enough to place content once its tracking state is precise. if (pose.trackingState != Vps2TrackingState.PRECISE) return onPoseUpdated(pose.pose) } } ``` Create the helper with your target coordinates where you own the `Vps2Session`, then call `updateContentPose` from your localization-updates callback and apply the returned `Pose` to a visible node: ```kotlin private val geoHelper = GeoPositionedObjectHelper( vps2Session = vps2Session, latitude = targetLatitude, longitude = targetLongitude, altitude = targetAltitude, ) // Inside your vps2Session.localizationUpdates handler: geoHelper.updateContentPose { pose -> val matrix = FloatArray(16) pose.toMatrix(matrix, 0) // apply `matrix` (column-major) to your rendered node's world transform } ``` The Kotlin sample app shows the full rendering wiring -- a visible `PoseNode` and `CubeNode` in `VPS2View.kt`, and the callback plumbing in `VPS2Manager.kt`. For reading the device's geolocation, see [Use geoposition with VPS2](https://www.nianticspatial.com/docs/nsdk/how-to/vps2/getting_vps2_geoposition/). ### Platform: unity ## Use a Site mesh when positioning content A Site mesh is optional for both placement approaches. It is a 3D model of the physical environment reconstructed from a scan. An app can render it, use it for collision or occlusion, or use it as a visual guide for placing content. The mesh itself does not track the Site. A Site mesh can support either placement approach: - For precise runtime placement, stream the mesh under the localized asset's trackable and save the content's asset-local transform. - For geo-coordinate placement, use a georeferenced mesh as an authoring reference for choosing coordinates. Runtime accuracy still depends on device localization and the map's geo-alignment. The following workflow demonstrates precise asset-relative placement. Tracking and mesh geometry are requested separately using the same VPS map asset ID: 1. Localize to the Site and track its localized asset, as shown in [Place content relative to a localized asset](#place-content-relative-to-an-anchor). 2. Stream the Site mesh with `LocationMeshManager.GetLocationMeshChunksByVpsMapAssetIdAsync`, passing the same asset ID and the Site's approximate coordinates. Chunks arrive one at a time, closest-first to the given position. 3. Add each downloaded chunk as a child of the asset's trackable without changing its local transform, then activate it. Chunks arrive inactive so they never flash at the scene origin. 4. Add virtual content as a child of the same trackable. Use the mesh as a guide for its local position, rotation, and scale. The following excerpt shows how the four steps connect. It is intentionally not a complete component: assign the manager and prefab references, and call the asynchronous portion from your app's localization flow. ```cs // 1. Track the localized asset reported by TryGetLatestLocalization. if (!_arVps2Manager.TryTrackAsset(localizedAssetId, out ARVps2Asset siteAsset)) return; // 2. Stream the Site mesh chunks using the same asset ID. await foreach (var chunk in _locationMeshManager.GetLocationMeshChunksByVpsMapAssetIdAsync( localizedAssetId, siteLatitude, siteLongitude, getTexture: true)) { // 3. Align each chunk with the asset's trackable, then activate it. chunk.transform.SetParent(siteAsset.transform, false); chunk.SetActive(true); } // 4. Place content relative to the same asset, using the mesh as a visual guide. GameObject content = Instantiate(_contentPrefab, siteAsset.transform); content.transform.localPosition = contentPositionOnMesh; content.transform.localRotation = contentRotationOnMesh; ``` See [Place content relative to a localized asset](#place-content-relative-to-an-anchor) for the full placement workflow, [Place content using geo coordinates](#place-content-using-geo-coordinates) for runtime geo placement, and [Mesh Download API](https://www.nianticspatial.com/docs/nsdk/how-to/vps/mesh_download/) for mesh download options. The `VPS2AssetLocalizeDemo.cs` script in the sample project implements this flow end to end. ## Test Your Placement In addition to testing your app on-location, you can also use Playback in order to iterate quickly without traveling to a VPS-enabled Site. Playback runs NSDK against a prerecorded AR session dataset instead of live camera input. It can exercise the full asset-relative and geo-coordinate workflows, as long as the dataset was recorded at the Site. Still, Playback is only a simulation of a live session, so it is best for iteration and debugging rather than final validation. On-location device testing is the most accurate way to confirm that content appears in the correct real-world place for the actual Site. See [How to set up Playback](https://www.nianticspatial.com/docs/nsdk/how-to/playback/setting_up_playback/).