Create playback datasets
Introduction
Use NSDK Recording to capture camera, motion, and sensor data from an AR session. Export the recording as a playback dataset for repeatable testing in the Unity Editor.
The Unity sample project is Niantic's working C# reference app. Its Recording scene demonstrates a complete capture and export flow, and it provides a runnable AR scene for the API tutorial.
Start by getting the sample project, then choose one of these independent workflows:
- Recording sample: Use Niantic's existing Recording scene to create a dataset quickly.
- Recording API: Build a separate tutorial component when you need control over the UI, capture state, archive creation, or sharing behavior.
You do not need to complete the Recording sample workflow before using the Recording API workflow.
In the Unity instructions:
- Sample project means Niantic's downloaded
nsdk-samples-csharpproject. - Recording sample means the
Recording.unityscene and its existingRecordingDemocomponent. - Tutorial component means the
PlaybackCaptureControlleryou create. - Your app means the Unity application where you will integrate the completed recording flow.
NSDK first saves a raw scan in the scan store. ScanArchiveBuilder then packages
that saved scan into one or more .tgz playback archives. The raw scan is the
source recording; the exported archives are the files you load with Playback.


Get the sample project
Both workflows use the public
nsdk-samples-csharp
repository. The published sample project is configured for Unity 6000.0.58f2;
install that Unity version with the Android or iOS build support module for
your target device when you want to validate the sample without changing it.
Use the Unity editor version declared by the sample project when you want to run the downloaded sample without upgrading it first. Opening a Unity project with a newer editor can update project metadata, package files, or generated settings. Upgrade the project only when you intend to validate against that newer editor.
Get and open the sample source:
-
In Terminal, clone the repository:
git clone https://github.com/nianticspatial/nsdk-samples-csharp.gitAlternatively, select Code > Download ZIP on GitHub, then extract the ZIP.
-
In Unity Hub, select Add > Add project from disk.
-
Select
<download-location>/nsdk-samples-csharp/NsdkSamples. The Unity project is in theNsdkSamplessubdirectory, not at the repository root. -
Open the project with Unity
6000.0.58f2and wait for package resolution and script compilation to finish. -
Authenticate NSDK before building:
- In Unity's menu bar, select NSDK > Settings.
- Sign in to your Scaniverse account, or provide a developer token under Edit > Project Settings > XR Plug-in Management > Niantic Spatial Development Kit > Credentials.
For more information about choosing and configuring an authentication method, see Authorization.
Record with the Recording sample
The Recording sample is the fastest way to create a playback dataset in Unity without building another recording interface.
Prepare the Recording sample
- In the Project window, open
Assets/Samples/Scanning/Scenes/Recording.unity. - Configure the project for Android or iOS by following Configure the build platform.
- Select File > Build Profiles and confirm that
Recordingis enabled in the scene list. - Connect a supported physical device and select Build and Run.
- On the device, grant camera and location access when prompted.
The first iOS recording requires an internet connection while NSDK downloads a resource used to convert altitude data. Wait for initialization to finish before starting capture.
Record the environment
- Tap Start.
- Move the device through the environment in portrait orientation. Hold it steadily enough for AR tracking and record the area from several viewpoints.
After tapping Start, record the environment for several seconds before tapping Stop. The Recording sample does not expose its recorded frame count in the UI. The API workflow adds an explicit frame-count gate before saving.
Stop and export
- Tap Stop after recording the environment.
- Wait for the save confirmation to appear, then tap Save to export the recording.
- Wait for Exporting Completed. Keep each
.tgzpath displayed in the export panel; a longer recording can produce more than one archive. - Use Retrieve and verify the recording to copy every archive and inspect its contents.
On iOS, you can also use Share to send a single exported archive to Files, AirDrop, or another app.
Record with the API
The Unity API gives you control over the recording UI, capture state,
frame-count gate, archive creation, and archive verification and sharing. This
workflow uses a copy of Niantic's Recording scene as a runnable development
environment. It creates a separate component and UI without replacing
RecordingDemo.cs.
The API walkthrough contains these sections:
- Capture implementation overview
- Prepare the tutorial scene
- Create PlaybackCaptureController
- Add capture controls
- Start capture
- Stop and save
- Export the archives
- Verify and share archives
- Retrieve and verify the recording
- View the complete PlaybackCaptureController
- Add to your app
Capture implementation overview
The API walkthrough starts from Niantic's Recording sample scene. These sample assets provide the runtime environment and reference implementation for the tutorial component:
| Sample asset | Responsibility |
|---|---|
Assets/Samples/Scanning/Scenes/Recording.unity | Contains the configured AR Session, XR Origin, camera, NSDK settings, and Recording sample UI. |
Assets/Samples/Scanning/Scripts/RecordingDemo.cs | Owns the sample recording controls, save/export flow, and scan visualization. |
ARScanningManager on AR Session | Provides the Recording API entry point used by the tutorial controller. |
IOSShare | Provides the sample iOS share-sheet helper used after export. |
The tutorial creates a separate scene and PlaybackCaptureController.cs so the
Recording sample remains unchanged. When the finished controller moves into an
app, that app must provide an AR Session with ARScanningManager, a valid
authentication path, camera frames, location data, and app-specific archive
handling.
Standalone development or internal-test builds can use a developer token from Credentials > Developer Tokens in Scaniverse web. Developer tokens should stay out of source control and public builds; see Authorization for the production flow.
Prepare the tutorial scene
Create a disposable scene beside Niantic's Recording sample so you can validate the API code without changing the reference implementation:
- In the Project window, select
Assets/Samples/Scanning/Scenes/Recording.unity, then select Edit > Duplicate. - Rename the copy
PlaybackCaptureTutorial.unity, then open it. - In the Hierarchy, select RecordingDemo. In the Inspector, clear the
checkbox beside the existing
RecordingDemocomponent so it cannot control the tutorial recording. - Select the existing Canvas GameObject and deactivate it from the Inspector so its controls do not overlap the tutorial UI.
- Create the tutorial UI:
-
Select GameObject > UI > Canvas and name it
PlaybackCaptureCanvas. -
With
PlaybackCaptureCanvasselected, configure its Canvas Scaler component to match the sample Canvas:Canvas Scaler field Value UI Scale Mode Scale With Screen Size Reference Resolution 2500x1440Screen Match Mode Match Width Or Height Match 0.5 -
With
PlaybackCaptureCanvasselected, use GameObject > UI > Button - TextMeshPro to create a button. In the Hierarchy, rename the new buttonStartCaptureButton. -
In the Hierarchy, expand
StartCaptureButtonand select its Text (TMP) child. In the Inspector, find the TextMeshPro - Text (UI) component and set Text Input toStart Capture. Set Font Size to48, and set Alignment to center and middle. -
Repeat the previous two actions to create the remaining buttons:
StopCaptureButtonwith Text Input set toStop Capture, andExportButtonwith Text Input set toExport. Use Font Size48and center-middle Alignment for both button labels. -
With
PlaybackCaptureCanvasselected, use GameObject > UI > Text - TextMeshPro to create a text object. In the Hierarchy, rename the new text objectCaptureStatus. -
Select
CaptureStatus. In the Inspector, find the TextMeshPro - Text (UI) component and set Text Input toReady to capture. Set Font Size to56, and set Alignment to center and middle. -
Arrange the controls in the lower center of the screen, based on the disabled Recording sample controls. For each object, select the object in the Hierarchy, find its Rect Transform component in the Inspector, and enter the following values:
CaptureStatus- Anchor Min: X
0.5, Y0 - Anchor Max: X
0.5, Y0 - Pivot: X
0.5, Y0.5 - Pos: X
0, Y720 - Width
1000, Height100
StartCaptureButton- Anchor Min: X
0.5, Y0 - Anchor Max: X
0.5, Y0 - Pivot: X
0.5, Y0.5 - Pos: X
-360, Y550 - Width
320, Height120
StopCaptureButton- Anchor Min: X
0.5, Y0 - Anchor Max: X
0.5, Y0 - Pivot: X
0.5, Y0.5 - Pos: X
0, Y550 - Width
320, Height120
ExportButton- Anchor Min: X
0.5, Y0 - Anchor Max: X
0.5, Y0 - Pivot: X
0.5, Y0.5 - Pos: X
360, Y550 - Width
320, Height120
- Anchor Min: X
-
- Select GameObject > Create Empty and name the new GameObject
PlaybackCaptureTutorial. - Select File > Build Profiles, then select Open Scene List. The sample
project includes several scenes and defaults to the
Homescene, so configure this tutorial build to include only the tutorial scene:- Clear the checkbox for each selected scene in the scene list.
- Select Add Open Scenes to add
PlaybackCaptureTutorial. - Confirm that
PlaybackCaptureTutorialis the only selected scene in the list.
The copied scene continues to provide the configured AR Session, XR Origin, camera, NSDK settings, and build configuration. The new component owns only the recording flow and its controls.
Validate this step:
- Select Build and Run.
- If Unity displays the Unsupported Input Handling dialog, select Yes. Unity might restart after updating the input handling setting. After Unity reopens the project, select Build and Run again.
- On the device, confirm that the app opens directly to
PlaybackCaptureTutorialinstead of the sample app'sHomescene. - Confirm that Start Capture, Stop Capture, Export, and Ready to capture are visible on the device.
The next steps add the script that connects the buttons to recording behavior.
Create PlaybackCaptureController
Create the component state and compiling placeholders used by the remaining steps:
- In the Project window, select
Assets/Samples/Scanning/Scripts, then select Assets > Create > Scripting > MonoBehaviour Script. - Name the file
PlaybackCaptureController.cs. - Open
Assets/Samples/Scanning/Scripts/PlaybackCaptureController.csand replace its contents with the following code.
Expand to create PlaybackCaptureController.cs with its initial state and placeholders
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using NianticSpatial.NSDK.AR.Scanning;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public sealed class PlaybackCaptureController : MonoBehaviour
{
// Connect these fields to the AR Scanning Manager and tutorial controls in
// the Inspector. In your app, reuse the manager and controls in its AR scene.
[SerializeField] private ARScanningManager _arScanningManager;
[SerializeField] private Button _startButton;
[SerializeField] private Button _stopButton;
[SerializeField] private Button _exportButton;
[SerializeField] private TMP_Text _statusText;
// The exporter creates another archive after this many frames. Adjust the
// value for your storage or upload limits.
[SerializeField, Min(1)] private int _maxFramesPerChunk = 900;
private ScanStore _scanStore;
private ScanStore.SavedScan _savedScan;
private readonly List<string> _archivePaths = new();
private Coroutine _captureStartRoutine;
private Coroutine _frameMonitorRoutine;
private bool _isPreparing;
private bool _isCapturing;
private bool _canStopCapture;
private bool _isSaving;
private bool _isExporting;
private void Awake()
{
if (_arScanningManager == null ||
_startButton == null ||
_stopButton == null ||
_exportButton == null ||
_statusText == null)
{
Debug.LogError(
"PlaybackCaptureController: assign the manager and all controls."
);
enabled = false;
return;
}
// Reuse the manager's scan store for the lifetime of this screen.
_scanStore = _arScanningManager.GetScanStore();
// If your UI already owns these actions, call the matching methods from
// its existing handlers instead of registering another listener.
_startButton.onClick.AddListener(StartCapture);
_stopButton.onClick.AddListener(StopAndSaveCapture);
_exportButton.onClick.AddListener(ExportCapture);
UpdateControls();
Debug.Log("PlaybackCaptureController: initialized");
}
private void OnDestroy()
{
_startButton?.onClick.RemoveListener(StartCapture);
_stopButton?.onClick.RemoveListener(StopAndSaveCapture);
_exportButton?.onClick.RemoveListener(ExportCapture);
StopRoutine(ref _captureStartRoutine);
StopRoutine(ref _frameMonitorRoutine);
// Stop an unfinished recording when this tutorial screen is destroyed.
// Do not unload your production screen while SaveScan() is running.
if (_isCapturing && _arScanningManager != null)
{
_arScanningManager.enabled = false;
}
}
private void UpdateControls()
{
// Placeholder: replaced in "Add capture controls."
}
public void StartCapture()
{
// Placeholder: replaced in "Start capture."
}
private IEnumerator StartWhenLocationIsReady()
{
// Placeholder: replaced in "Start capture."
yield break;
}
private IEnumerator WaitForFirstFrame()
{
// Placeholder: replaced in "Start capture."
yield break;
}
public async void StopAndSaveCapture()
{
// Placeholder: replaced in "Stop and save."
await Task.CompletedTask;
}
public async void ExportCapture()
{
// Placeholder: replaced in "Export the archives."
await Task.CompletedTask;
}
private void HandleArchives()
{
// Placeholder: replaced in "Verify and share archives."
}
private void SetStatus(string message)
{
_statusText.text = message;
Debug.Log($"PlaybackCaptureController: {message}");
}
private void StopRoutine(ref Coroutine routine)
{
if (routine == null)
{
return;
}
StopCoroutine(routine);
routine = null;
}
}
- Return to Unity and wait for script compilation to finish.
- Select PlaybackCaptureTutorial in the Hierarchy, then select Add Component > Playback Capture Controller.
- Assign the component fields in the Inspector:
- Drag AR Session to Ar Scanning Manager.
- Drag
StartCaptureButton,StopCaptureButton, andExportButtonto their matching button fields. - Drag
CaptureStatusto Status Text.
- Select AR Session and confirm that AR Scanning Manager is disabled so the tutorial component controls when recording starts.
Validate this step:
-
Select Build and Run.
-
Confirm
PlaybackCaptureController: initializedappears in the device log for your platform:-
For Android, open View > Tool Windows > Logcat in Android Studio. Select the connected device and app process, then search for
PlaybackCaptureController: initialized.As a Terminal alternative, run:
adb logcat | grep "PlaybackCaptureController: initialized"Press Control+C to stop watching the log.
-
For iOS, build the iOS project from Unity, open the generated Xcode project, and run the app on the connected device from Xcode. Select View > Debug Area > Activate Console, then search the debug console for
PlaybackCaptureController: initialized.
-
Add capture controls
In Assets/Samples/Scanning/Scripts/PlaybackCaptureController.cs, locate the
placeholder UpdateControls() method and replace the entire method with the
following implementation:
private void UpdateControls()
{
// Keep capture actions mutually exclusive. Preserve the frame-count gate
// if your app displays this state with different controls.
bool isBusy = _isPreparing || _isSaving || _isExporting;
_startButton.interactable = !isBusy && !_isCapturing;
_stopButton.interactable = !isBusy && _isCapturing && _canStopCapture;
_exportButton.interactable = !isBusy && !_isCapturing && _savedScan != null;
Debug.Log(
"PlaybackCaptureController: controls updated; " +
$"start={_startButton.interactable}, " +
$"stop={_stopButton.interactable}, " +
$"export={_exportButton.interactable}"
);
}
The component derives every control state from the recording lifecycle. Stop remains disabled until NSDK reports a recorded frame, and Export remains disabled until a saved scan exists.
Validate this step:
-
Build and run the tutorial scene.
-
Confirm the device log contains the initial control state:
PlaybackCaptureController: controls updated; start=True, stop=False, export=FalseFor Android, use Android Studio Logcat to search for
PlaybackCaptureController: controls updated, or run:adb logcat | grep "PlaybackCaptureController: controls updated"For iOS, run the app from Xcode and search the debug console for
PlaybackCaptureController: controls updated.
The next step adds the capture-start behavior.
Start capture
In Assets/Samples/Scanning/Scripts/PlaybackCaptureController.cs, add the
Android permission import with the other using directives. Keep the import
inside the platform check so iOS builds do not depend on Android-only APIs:
#if UNITY_ANDROID
using UnityEngine.Android;
#endif
Then replace the placeholder StartCapture(), StartWhenLocationIsReady(),
and WaitForFirstFrame() methods with the following implementations. The
Android build requests location permission when necessary. Both Android and iOS
builds initialize Unity's location and compass services, enable the scanning
manager, and open the stop gate only after NSDK records a frame:
Expand to replace the Unity start-capture methods
public void StartCapture()
{
if (_isPreparing || _isCapturing || _isSaving || _isExporting)
{
return;
}
// Android requires runtime location permission before Unity can start its
// location service. Replace this block with your app's existing permission
// flow if permissions are requested somewhere else.
#if UNITY_ANDROID && !UNITY_EDITOR
if (!Permission.HasUserAuthorizedPermission(Permission.FineLocation))
{
_isPreparing = true;
UpdateControls();
SetStatus("waiting for location permission");
var callbacks = new PermissionCallbacks();
callbacks.PermissionGranted += permissionName =>
{
if (permissionName == Permission.FineLocation)
{
// Permission is now available. Enter StartCapture() again so
// the shared Android/iOS startup path following this block runs.
_isPreparing = false;
StartCapture();
}
};
callbacks.PermissionDenied += permissionName =>
{
_isPreparing = false;
SetStatus("location permission is required for capture");
UpdateControls();
};
Permission.RequestUserPermission(Permission.FineLocation, callbacks);
return;
}
#endif
// Shared Android/iOS setup: clear any previous tutorial recording state,
// update the controls, then wait for location before enabling recording.
// In your app, keep this reset near the code that owns the capture UI.
_isPreparing = true;
_savedScan = null;
_archivePaths.Clear();
UpdateControls();
_captureStartRoutine = StartCoroutine(StartWhenLocationIsReady());
}
private IEnumerator StartWhenLocationIsReady()
{
// NSDK records location and compass data with the camera frames. Replace
// this startup block with your app's existing location-readiness signal if
// your app already owns location and compass initialization.
Input.compass.enabled = true;
if (Input.location.status == LocationServiceStatus.Stopped)
{
Input.location.Start();
}
const float timeoutSeconds = 20f;
float elapsedSeconds = 0f;
while (Input.location.status != LocationServiceStatus.Running &&
Input.location.status != LocationServiceStatus.Failed &&
elapsedSeconds < timeoutSeconds)
{
elapsedSeconds += Time.unscaledDeltaTime;
yield return null;
}
_captureStartRoutine = null;
if (Input.location.status != LocationServiceStatus.Running)
{
_isPreparing = false;
SetStatus("location initialization failed");
UpdateControls();
yield break;
}
_canStopCapture = false;
_isPreparing = false;
_isCapturing = true;
// Enabling ARScanningManager requests that recording begin. It is not safe
// to stop yet, so Stop Capture stays disabled until WaitForFirstFrame()
// observes GetFrameCount() > 0.
_arScanningManager.enabled = true;
SetStatus("capture start requested");
UpdateControls();
_frameMonitorRoutine = StartCoroutine(WaitForFirstFrame());
}
private IEnumerator WaitForFirstFrame()
{
// Recording startup can lag behind the button tap. Poll the NSDK frame
// count until the recording contains at least one saved frame. If your app
// already exposes capture-readiness state, use that signal instead.
var pollingDelay = new WaitForSecondsRealtime(0.2f);
while (_isCapturing)
{
int frameCount = _arScanningManager.GetFrameCount();
if (frameCount > 0)
{
// From this point forward, SaveScan() has frame data to save, so
// the UI can enable Stop Capture.
_canStopCapture = true;
_frameMonitorRoutine = null;
SetStatus($"capture ready to stop; frameCount={frameCount}");
UpdateControls();
yield break;
}
yield return pollingDelay;
}
_frameMonitorRoutine = null;
}
Enabling ARScanningManager requests that recording begin, but a scan cannot be
saved until GetFrameCount() is greater than zero. The initialization interval
can vary. Keep the stop action disabled until the SDK confirms that it has
recorded a frame.
Validate this step:
-
Build and run the tutorial scene, then grant camera and location permissions if the device requests them.
-
Tap Start Capture and move the device through the environment.
-
Confirm the device log contains both messages and that the second frame count is greater than zero:
PlaybackCaptureController: capture start requested
PlaybackCaptureController: capture ready to stop; frameCount= -
Confirm that Stop Capture is enabled after the second message appears.
The next section replaces the placeholder stop method with the save behavior.
Stop and save
Add the following imports to
Assets/Samples/Scanning/Scripts/PlaybackCaptureController.cs:
using System;
using System.Linq;
Replace the placeholder StopAndSaveCapture() method with the following code.
It rechecks the frame count, reads the scan ID while capture is active, keeps the
scanning manager enabled throughout the asynchronous save, and then resolves the
saved scan from the scan store:
Expand to replace the Unity stop-and-save method
public async void StopAndSaveCapture()
{
// Save only after recording has started and the frame-count gate has opened.
// In your app, keep the same guard even if another UI element calls save.
if (!_isCapturing || !_canStopCapture || _isSaving)
{
return;
}
// Recheck at the save boundary so callers other than the button cannot
// ask SaveScan() to save an empty recording.
int frameCount = _arScanningManager.GetFrameCount();
if (frameCount <= 0)
{
_canStopCapture = false;
UpdateControls();
return;
}
// Read the ID while scanning is active; GetCurrentScanId() is only
// guaranteed to return it while a scan is in progress.
string scanId = _arScanningManager.GetCurrentScanId();
if (string.IsNullOrEmpty(scanId))
{
SetStatus("save failed: scan ID is unavailable");
return;
}
StopRoutine(ref _frameMonitorRoutine);
// Switch the tutorial UI into a saving state before awaiting SaveScan().
// Replace these flags with your app's own state model if needed.
_isCapturing = false;
_canStopCapture = false;
_isSaving = true;
SetStatus($"save started; frameCount={frameCount}");
UpdateControls();
try
{
// Keep ARScanningManager enabled until this asynchronous save
// completes. Disabling it earlier can interrupt the recording.
await _arScanningManager.SaveScan();
_arScanningManager.enabled = false;
// The exporter needs the SavedScan object. If your app stores scans in
// another model, replace this lookup with that source of truth.
_savedScan = _scanStore.GetSavedScans()
.FirstOrDefault(scan => scan.ScanId == scanId);
if (_savedScan == null)
{
throw new InvalidOperationException(
$"Saved scan {scanId} was not found in the scan store."
);
}
SetStatus($"save completed; scanId={scanId}");
}
catch (Exception exception)
{
_arScanningManager.enabled = false;
_savedScan = null;
Debug.LogException(exception);
// Replace this status text with your app's error surface.
SetStatus($"save failed: {exception.Message}");
}
finally
{
// Always leave the UI in a non-saving state after success or failure.
_isSaving = false;
UpdateControls();
}
}
Validate this step:
-
Build and run the tutorial scene.
-
Start capture and wait for Stop Capture to become enabled.
-
Tap Stop Capture.
-
Confirm that tapping Stop Capture caused the device log to contain the following messages in order. The first frame count must be greater than zero, and the second message must contain a scan ID:
PlaybackCaptureController: save started; frameCount=
PlaybackCaptureController: save completed; scanId= -
Confirm that Start Capture and Export are enabled after saving.
Do not tap Export for this validation. At this point in the walkthrough,
ExportCapture() is still a placeholder. The next section adds the export code
and tells you when to tap Export.
Export the archives
In Assets/Samples/Scanning/Scripts/PlaybackCaptureController.cs, replace the
placeholder ExportCapture() method with the following implementation. It
passes the SavedScan directly to ScanArchiveBuilder, disposes the builder's
native resources, and stores every path returned by the exporter:
Expand to replace the Unity export method
public async void ExportCapture()
{
// Export needs a saved scan. In your app, keep the export action disabled
// until the save flow has produced the SavedScan you want to package.
if (_savedScan == null || _isSaving || _isExporting)
{
return;
}
// Track export state for this tutorial UI. Replace this with your app's
// progress, loading, or job state if export runs from another layer.
_isExporting = true;
_archivePaths.Clear();
SetStatus($"export started; scanId={_savedScan.ScanId}");
UpdateControls();
try
{
// ScanArchiveBuilder converts the saved recording into one or more
// playback archives. It owns native resources, so always dispose it.
using var builder = new ScanArchiveBuilder(
_savedScan,
// Replace UploadUserInfo with your app's upload/user metadata if
// your backend expects it. Leave it empty for local validation.
new UploadUserInfo(),
// Tune this value for your storage, upload, or sharing limits.
_maxFramesPerChunk
);
if (!builder.IsValid())
{
throw new InvalidOperationException(
"The scan archive builder could not be created."
);
}
while (builder.HasMoreChunks())
{
// Each chunk task writes one .tgz archive. The returned value is
// the actual file path; use it instead of constructing a path from
// the scan ID.
Task<string> exportTask = builder.CreateTaskToGetNextChunk();
exportTask.Start();
string archivePath = await exportTask;
if (!string.IsNullOrEmpty(archivePath))
{
_archivePaths.Add(archivePath);
Debug.Log(
$"PlaybackCaptureController: archive exported: {archivePath}"
);
}
}
if (_archivePaths.Count == 0)
{
throw new InvalidOperationException("No playback archives were exported.");
}
SetStatus($"export completed; archiveCount={_archivePaths.Count}");
// Replace HandleArchives() with your app's upload, storage, or sharing
// flow when you integrate the exporter outside this tutorial.
HandleArchives();
}
catch (Exception exception)
{
Debug.LogException(exception);
// Replace this status text with your app's error surface.
SetStatus($"export failed: {exception.Message}");
}
finally
{
// Always clear the exporting state so the UI does not stay disabled
// after a successful export or a handled failure.
_isExporting = false;
UpdateControls();
}
}
The maxFramesPerChunk value controls the maximum number of frames in each
output archive. A recording with more frames produces multiple .tgz files.
ScanArchiveBuilder does not provide a fractional progress callback, so update
your UI when each returned chunk finishes.
Validate this step:
- Build and run the tutorial scene, then start and stop a recording.
- Tap Export.
- Confirm the device log contains
export started; scanId=, followed by one or morearchive exported:messages containing.tgzpaths. - Confirm
export completed; archiveCount=reports the number of exported paths. Archive verification and sharing are still placeholders and are added in the next section.
Verify and share archives
Add the following import to
Assets/Samples/Scanning/Scripts/PlaybackCaptureController.cs:
using System.IO;
Replace the placeholder HandleArchives() method with the following code. It
verifies every returned file and logs its path. On iOS, Niantic's sample project
also provides IOSShare, which opens the system share sheet when the recording
fits in one archive:
Expand to replace the Unity archive verification and sharing method
private void HandleArchives()
{
foreach (string archivePath in _archivePaths)
{
if (!File.Exists(archivePath))
{
Debug.LogError(
$"PlaybackCaptureController: archive not found: {archivePath}"
);
continue;
}
Debug.Log($"PlaybackCaptureController: archive ready: {archivePath}");
}
#if UNITY_IOS && !UNITY_EDITOR
// Niantic's sample app includes IOSShare. Replace it with your product's
// upload or storage flow if the archive should not use the share sheet.
if (_archivePaths.Count == 1 && File.Exists(_archivePaths[0]))
{
IOSShare.ShareFile(
_archivePaths[0],
"Sharing playback recording"
);
Debug.Log(
$"PlaybackCaptureController: share sheet opened: {_archivePaths[0]}"
);
}
#endif
}
The public Unity sample does not include an Android FileProvider sharing flow.
On Android, use the logged archive paths for retrieval or replace
HandleArchives() with your app's upload, storage, or sharing implementation.
Do the same on iOS if your product should not use the system share sheet.
Validate this step:
- Build and run the tutorial scene.
- Tap Start Capture, move the device through the environment, wait for Stop Capture to become enabled, then tap Stop Capture.
- After the save completes and Export is enabled, tap Export.
- Confirm each
archive exported:path has a matchingarchive ready:path. - Confirm the device log does not contain
archive not foundorexport failed.
Expected result by platform:
- Android: no share sheet opens because the public Unity sample does not include an Android sharing implementation.
- iOS: a single-archive recording opens the share sheet, and
share sheet opened:contains the same path.
Retrieve and verify the recording
Use the archive ready: log from the previous section to identify each .tgz
file that export created. The save completed; scanId= log names the recording
folder, but the archive ready: log gives the full archive path to retrieve.
File locations
If Scan Path is empty on ARScanningManager, Unity recordings use these
default locations:
| Platform | Default Unity recording directory |
|---|---|
| iOS | The app's Documents/scankit/ directory |
| Android | /sdcard/Android/data/{app.package.name}/files/scankit/ |
| macOS Editor | /Users/{username}/Library/Application Support/{companyName}/{productName}/scankit/ |
| Windows Editor | C:\Users\{username}\AppData\LocalLow\{companyName}\{productName}\scankit\ |
The saved location is also available from the
ScanStore.SavedScan.ScanBasePath
property. If your app configures Scan Path, use that location instead of the
defaults.
Retrieve from Android
-
In the device log, copy each full path from
archive ready:. The path should end in.tgz, for example:/sdcard/Android/data/com.nianticspatial.nsdk.nsdksamples/files/scankit/SCAN_ID/chunk_0.tgz -
Copy the file with either Android Studio Device Explorer or
adb pull:-
Android Studio Device Explorer is the most reliable option when the device restricts direct command-line access to app-specific external storage:
- In Android Studio, select View > Tool Windows > Device Explorer, then select the connected device.
- Navigate to the directory shown in the
archive ready:path. - Select each
.tgzfile, then use Save As in the Device Explorer toolbar to copy it to the development machine.
-
adb pullis the direct command-line option when the path is accessible. Replace the path in this command with the path from your device log:adb pull /sdcard/Android/data/com.nianticspatial.nsdk.nsdksamples/files/scankit/SCAN_ID/chunk_0.tgz .The final
.copies the archive into the terminal's current directory.
-
-
Repeat the copy action for each additional
archive ready:path.
For your own app, replace com.nianticspatial.nsdk.nsdksamples with its Android
application ID.
Retrieve from iOS
On iOS, use the share sheet from the previous section when it opens. Save the archive to Files, send it with AirDrop, or share it with another app.
If you need to retrieve the archive directly from the app container, use Finder.
The public sample's Assets/Editor/PostBuildProcess.cs adds
UIFileSharingEnabled and LSSupportsOpeningDocumentsInPlace to the generated
app. These keys expose the app's Documents directory in Finder.
- Keep the iOS device connected and unlocked.
- In Finder, select the device in the sidebar, then select Files.
- Expand NSDK Unity Samples and drag the
scankitfolder to the Mac. - Open the scan-ID directory reported by
save completed; scanId=. - Locate the
.tgzfile named in thearchive ready:path.
When you integrate recording into your own Unity app, add the same keys in its iOS post-build processor:
rootDict.SetBoolean("UIFileSharingEnabled", true);
rootDict.SetBoolean("LSSupportsOpeningDocumentsInPlace", true);
Verify the exported files
- Extract each
.tgzarchive with the operating system's built-in archive support:- On macOS, select the file in Finder, then select File > Open.
- On Windows 11 version 24H2 or later, select the file in File Explorer, then select Extract All.
- On Linux, select the file in the system file manager, then select its Extract action. The action name depends on the desktop environment.
- In the extracted files, confirm that
capture.jsonandframe_*.jpgare present. - Open
capture.jsonin a text editor and locate its top-levelframeCountproperty. Confirm that the value is greater than zero. - Confirm that frame numbering begins with
frame_00000000.jpg. Across all chunks, the number of frame images should correspond to the exported frame count. - Open several
frame_*.jpgfiles and confirm that they show the environment you recorded.
Depth and confidence files are device- and configuration-dependent, so their absence does not by itself mean the playback dataset is invalid. To validate the recording by replaying it, continue with Set up Playback.
View the complete PlaybackCaptureController
The expandable section contains the complete
PlaybackCaptureController.cs assembled in the preceding steps. Use it to
check your completed component, recover a missed change, or copy the full
implementation without repeating the walkthrough. Refer to this code for the
tutorial's finished result; Niantic's existing RecordingDemo.cs is a separate
sample implementation and does not correspond line-for-line with these steps.
Expand to reveal PlaybackCaptureController.cs
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using NianticSpatial.NSDK.AR.Scanning;
using TMPro;
using UnityEngine;
#if UNITY_ANDROID
using UnityEngine.Android;
#endif
using UnityEngine.UI;
public sealed class PlaybackCaptureController : MonoBehaviour
{
// Connect these fields to the AR Scanning Manager and tutorial controls in
// the Inspector. In your app, reuse the manager and controls in its AR scene.
[SerializeField] private ARScanningManager _arScanningManager;
[SerializeField] private Button _startButton;
[SerializeField] private Button _stopButton;
[SerializeField] private Button _exportButton;
[SerializeField] private TMP_Text _statusText;
// The exporter creates another archive after this many frames. Adjust the
// value for your storage or upload limits.
[SerializeField, Min(1)] private int _maxFramesPerChunk = 900;
private ScanStore _scanStore;
private ScanStore.SavedScan _savedScan;
private readonly List<string> _archivePaths = new();
private Coroutine _captureStartRoutine;
private Coroutine _frameMonitorRoutine;
private bool _isPreparing;
private bool _isCapturing;
private bool _canStopCapture;
private bool _isSaving;
private bool _isExporting;
private void Awake()
{
if (_arScanningManager == null ||
_startButton == null ||
_stopButton == null ||
_exportButton == null ||
_statusText == null)
{
Debug.LogError(
"PlaybackCaptureController: assign the manager and all controls."
);
enabled = false;
return;
}
// Reuse the manager's scan store for the lifetime of this screen.
_scanStore = _arScanningManager.GetScanStore();
// If your UI already owns these actions, call the matching methods from
// its existing handlers instead of registering another listener.
_startButton.onClick.AddListener(StartCapture);
_stopButton.onClick.AddListener(StopAndSaveCapture);
_exportButton.onClick.AddListener(ExportCapture);
UpdateControls();
Debug.Log("PlaybackCaptureController: initialized");
}
private void OnDestroy()
{
_startButton?.onClick.RemoveListener(StartCapture);
_stopButton?.onClick.RemoveListener(StopAndSaveCapture);
_exportButton?.onClick.RemoveListener(ExportCapture);
StopRoutine(ref _captureStartRoutine);
StopRoutine(ref _frameMonitorRoutine);
// Stop an unfinished recording when this tutorial screen is destroyed.
// Do not unload your production screen while SaveScan() is running.
if (_isCapturing && _arScanningManager != null)
{
_arScanningManager.enabled = false;
}
}
private void UpdateControls()
{
// Keep capture actions mutually exclusive. Preserve the frame-count gate
// if your app displays this state with different controls.
bool isBusy = _isPreparing || _isSaving || _isExporting;
_startButton.interactable = !isBusy && !_isCapturing;
_stopButton.interactable = !isBusy && _isCapturing && _canStopCapture;
_exportButton.interactable = !isBusy && !_isCapturing && _savedScan != null;
Debug.Log(
"PlaybackCaptureController: controls updated; " +
$"start={_startButton.interactable}, " +
$"stop={_stopButton.interactable}, " +
$"export={_exportButton.interactable}"
);
}
public void StartCapture()
{
if (_isPreparing || _isCapturing || _isSaving || _isExporting)
{
return;
}
// Android requires runtime location permission before Unity can start its
// location service. Replace this block with your app's existing permission
// flow if permissions are requested somewhere else.
#if UNITY_ANDROID && !UNITY_EDITOR
if (!Permission.HasUserAuthorizedPermission(Permission.FineLocation))
{
_isPreparing = true;
UpdateControls();
SetStatus("waiting for location permission");
var callbacks = new PermissionCallbacks();
callbacks.PermissionGranted += permissionName =>
{
if (permissionName == Permission.FineLocation)
{
// Permission is now available. Enter StartCapture() again so
// the shared Android/iOS startup path following this block runs.
_isPreparing = false;
StartCapture();
}
};
callbacks.PermissionDenied += permissionName =>
{
_isPreparing = false;
SetStatus("location permission is required for capture");
UpdateControls();
};
Permission.RequestUserPermission(Permission.FineLocation, callbacks);
return;
}
#endif
// Shared Android/iOS setup: clear any previous tutorial recording state,
// update the controls, then wait for location before enabling recording.
// In your app, keep this reset near the code that owns the capture UI.
_isPreparing = true;
_savedScan = null;
_archivePaths.Clear();
UpdateControls();
_captureStartRoutine = StartCoroutine(StartWhenLocationIsReady());
}
private IEnumerator StartWhenLocationIsReady()
{
// NSDK records location and compass data with the camera frames. Replace
// this startup block with your app's existing location-readiness signal if
// your app already owns location and compass initialization.
Input.compass.enabled = true;
if (Input.location.status == LocationServiceStatus.Stopped)
{
Input.location.Start();
}
const float timeoutSeconds = 20f;
float elapsedSeconds = 0f;
while (Input.location.status != LocationServiceStatus.Running &&
Input.location.status != LocationServiceStatus.Failed &&
elapsedSeconds < timeoutSeconds)
{
elapsedSeconds += Time.unscaledDeltaTime;
yield return null;
}
_captureStartRoutine = null;
if (Input.location.status != LocationServiceStatus.Running)
{
_isPreparing = false;
SetStatus("location initialization failed");
UpdateControls();
yield break;
}
_canStopCapture = false;
_isPreparing = false;
_isCapturing = true;
// Enabling ARScanningManager requests that recording begin. It is not safe
// to stop yet, so Stop Capture stays disabled until WaitForFirstFrame()
// observes GetFrameCount() > 0.
_arScanningManager.enabled = true;
SetStatus("capture start requested");
UpdateControls();
_frameMonitorRoutine = StartCoroutine(WaitForFirstFrame());
}
private IEnumerator WaitForFirstFrame()
{
// Recording startup can lag behind the button tap. Poll the NSDK frame
// count until the recording contains at least one saved frame. If your app
// already exposes capture-readiness state, use that signal instead.
var pollingDelay = new WaitForSecondsRealtime(0.2f);
while (_isCapturing)
{
int frameCount = _arScanningManager.GetFrameCount();
if (frameCount > 0)
{
// From this point forward, SaveScan() has frame data to save, so
// the UI can enable Stop Capture.
_canStopCapture = true;
_frameMonitorRoutine = null;
SetStatus($"capture ready to stop; frameCount={frameCount}");
UpdateControls();
yield break;
}
yield return pollingDelay;
}
_frameMonitorRoutine = null;
}
public async void StopAndSaveCapture()
{
// Save only after recording has started and the frame-count gate has opened.
// In your app, keep the same guard even if another UI element calls save.
if (!_isCapturing || !_canStopCapture || _isSaving)
{
return;
}
// Recheck at the save boundary so callers other than the button cannot
// ask SaveScan() to save an empty recording.
int frameCount = _arScanningManager.GetFrameCount();
if (frameCount <= 0)
{
_canStopCapture = false;
UpdateControls();
return;
}
// Read the ID while scanning is active; GetCurrentScanId() is only
// guaranteed to return it while a scan is in progress.
string scanId = _arScanningManager.GetCurrentScanId();
if (string.IsNullOrEmpty(scanId))
{
SetStatus("save failed: scan ID is unavailable");
return;
}
StopRoutine(ref _frameMonitorRoutine);
// Switch the tutorial UI into a saving state before awaiting SaveScan().
// Replace these flags with your app's own state model if needed.
_isCapturing = false;
_canStopCapture = false;
_isSaving = true;
SetStatus($"save started; frameCount={frameCount}");
UpdateControls();
try
{
// Keep ARScanningManager enabled until this asynchronous save
// completes. Disabling it earlier can interrupt the recording.
await _arScanningManager.SaveScan();
_arScanningManager.enabled = false;
// The exporter needs the SavedScan object. If your app stores scans in
// another model, replace this lookup with that source of truth.
_savedScan = _scanStore.GetSavedScans()
.FirstOrDefault(scan => scan.ScanId == scanId);
if (_savedScan == null)
{
throw new InvalidOperationException(
$"Saved scan {scanId} was not found in the scan store."
);
}
SetStatus($"save completed; scanId={scanId}");
}
catch (Exception exception)
{
_arScanningManager.enabled = false;
_savedScan = null;
Debug.LogException(exception);
// Replace this status text with your app's error surface.
SetStatus($"save failed: {exception.Message}");
}
finally
{
// Always leave the UI in a non-saving state after success or failure.
_isSaving = false;
UpdateControls();
}
}
public async void ExportCapture()
{
// Export needs a saved scan. In your app, keep the export action disabled
// until the save flow has produced the SavedScan you want to package.
if (_savedScan == null || _isSaving || _isExporting)
{
return;
}
// Track export state for this tutorial UI. Replace this with your app's
// progress, loading, or job state if export runs from another layer.
_isExporting = true;
_archivePaths.Clear();
SetStatus($"export started; scanId={_savedScan.ScanId}");
UpdateControls();
try
{
// ScanArchiveBuilder converts the saved recording into one or more
// playback archives. It owns native resources, so always dispose it.
using var builder = new ScanArchiveBuilder(
_savedScan,
// Replace UploadUserInfo with your app's upload/user metadata if
// your backend expects it. Leave it empty for local validation.
new UploadUserInfo(),
// Tune this value for your storage, upload, or sharing limits.
_maxFramesPerChunk
);
if (!builder.IsValid())
{
throw new InvalidOperationException(
"The scan archive builder could not be created."
);
}
while (builder.HasMoreChunks())
{
// Each chunk task writes one .tgz archive. The returned value is
// the actual file path; use it instead of constructing a path from
// the scan ID.
Task<string> exportTask = builder.CreateTaskToGetNextChunk();
exportTask.Start();
string archivePath = await exportTask;
if (!string.IsNullOrEmpty(archivePath))
{
_archivePaths.Add(archivePath);
Debug.Log(
$"PlaybackCaptureController: archive exported: {archivePath}"
);
}
}
if (_archivePaths.Count == 0)
{
throw new InvalidOperationException("No playback archives were exported.");
}
SetStatus($"export completed; archiveCount={_archivePaths.Count}");
// Replace HandleArchives() with your app's upload, storage, or sharing
// flow when you integrate the exporter outside this tutorial.
HandleArchives();
}
catch (Exception exception)
{
Debug.LogException(exception);
// Replace this status text with your app's error surface.
SetStatus($"export failed: {exception.Message}");
}
finally
{
// Always clear the exporting state so the UI does not stay disabled
// after a successful export or a handled failure.
_isExporting = false;
UpdateControls();
}
}
private void HandleArchives()
{
foreach (string archivePath in _archivePaths)
{
if (!File.Exists(archivePath))
{
Debug.LogError(
$"PlaybackCaptureController: archive not found: {archivePath}"
);
continue;
}
Debug.Log($"PlaybackCaptureController: archive ready: {archivePath}");
}
#if UNITY_IOS && !UNITY_EDITOR
// Niantic's sample app includes IOSShare. Replace it with your product's
// upload or storage flow if the archive should not use the share sheet.
if (_archivePaths.Count == 1 && File.Exists(_archivePaths[0]))
{
IOSShare.ShareFile(
_archivePaths[0],
"Sharing playback recording"
);
Debug.Log(
$"PlaybackCaptureController: share sheet opened: {_archivePaths[0]}"
);
}
#endif
}
private void SetStatus(string message)
{
_statusText.text = message;
Debug.Log($"PlaybackCaptureController: {message}");
}
private void StopRoutine(ref Coroutine routine)
{
if (routine == null)
{
return;
}
StopCoroutine(routine);
routine = null;
}
}
Add to your app
The tutorial scene supplies several dependencies that your existing AR scene may already own:
| Tutorial dependency | Use in your app |
|---|---|
ARScanningManager on AR Session | Reuse the scanning manager in the scene that owns your AR session. |
| Tutorial buttons and status label | Connect your existing controls, or move the state transitions into your UI layer. |
Input.location and Input.compass initialization | Keep your app's existing location and compass lifecycle if it already provides one. |
IOSShare | Replace this sample helper with your product's upload, storage, or sharing flow. |
Move the completed recording flow into your Unity app:
- Confirm that the app has NSDK installed, an AR Session and XR Origin, a valid authentication path, and Scanning enabled under Edit > Project Settings > XR Plug-in Management > Niantic Spatial Development Kit.
- Copy
PlaybackCaptureController.csinto the app, or move its state and methods into the component that already owns recording UI. - Add or reuse an
ARScanningManageron the app's AR Session GameObject. Leave the component disabled untilStartCapture()enables it. - Connect the scanning manager and the app's start, stop, export, and status UI
to the serialized fields. If another component already registers the button
events, remove the listener registration from
Awake()and call the corresponding methods from the existing handlers. - Replace the Android permission and Unity location initialization with the app's existing permission flow if it has one. Preserve the requirement that location and compass data are available before recording starts.
- Replace
HandleArchives()with the app's upload, storage, or sharing behavior. If the app does not include Niantic'sIOSSharehelper, remove or replace that iOS-only call before building for iOS. - Prevent scene navigation while
SaveScan()is running. KeepARScanningManagerenabled until the save completes, then stop it during the AR screen's normal cleanup. - Build and run the app on a supported physical device, then validate the
complete lifecycle:
- Open the AR screen and its Android or iOS device log.
- Start capture and confirm
capture start requestedappears. - Confirm
capture ready to stop; frameCount=reports a value greater than zero before Stop becomes available. - Stop capture and confirm the save-started and save-completed messages appear in order.
- Export the recording and confirm every
archive exported:path has a matchingarchive ready:path. - Retrieve the archives and confirm
capture.jsonreports a frame count greater than zero. - Leave and reopen the AR scene, then complete a second capture and export. Confirm that the first component does not retain a coroutine or active scanning manager.
- Confirm the device log does not report
location initialization failed,save failed,export failed, orarchive not found.
Continue with Set up Playback to load the exported recording in the Unity Editor.