Skip to content
Your own AR Foundation app

Your own AR Foundation app

This page is for wiring LansAnchor into an AR Foundation app you already have. It is not the AR Foundation sample, which is a finished reference app. If you want the fastest way to see the flow working before you touch your own scene, start there instead.

You don’t need to know anything about mesh registration to use this. The pieces below give you three things: a live mesh feed for ARKit’s scene reconstruction, a scan-and-place flow, and a scan-and-find flow. Everything else, RANSAC, ICP, verification gates, stays internal; see how it works if you’re curious.

Current state of this page’s building blocks. The scripting API below (AnchorCreationFlow/LocalizationFlow/ManualAnchoring/MultiRoomAnchoring, AnchorPayloadPersistence, LicenseInfo) ships in the com.lansenou.unitybridge package today. The AR Foundation-specific helper components this page also documents are different: the live-mesh adapter, the beacon/verdict UI, certainty tiering, and the always-on session log. They currently live only in this repo’s Unity project (Assets/MeshBasedAnchoring/ARFoundationBridge/) and have not yet been split into the package’s Samples~/. If you installed the package from the registry, you have the scripting API section below. For the AR Foundation-specific pieces, copy the referenced files out of this repo until they ship as a sample.

Prerequisites

  • Unity 6000.3 or later.
  • AR Foundation (com.unity.xr.arfoundation) with the ARKit provider enabled under Project Settings > XR Plug-in Management.
  • An iPhone with LiDAR for testing (A14/iPhone 12 Pro or later, or any iPhone 15+).
  • com.lansenou.unitybridge installed. See Install.

Scene setup

The pieces ARKit needs

Every AR Foundation scene needs the usual trio, unrelated to this SDK:

  • An ARSession.
  • An XR Origin (with ARCameraManager + ARCameraBackground on its camera).
  • An ARMeshManager, added as a child of the XR Origin, with a mesh prefab assigned. A plain GameObject with a MeshFilter + MeshRenderer is enough. The shipped sample also adds a wireframe-rendering component, which is cosmetic and not required for registration. The sample runs ARMeshManager’s Density at 0.5, fine to start from.

Getting a mesh source

Registration reads scene geometry as GetComponentsInChildren<MeshFilter>() under one root Transform. It has no idea ARMeshManager exists. You need one small adapter that:

  1. Subscribes to ARMeshManager.meshesChanged.
  2. Tracks the resulting MeshFilters.
  3. Exposes a stable root Transform whose children are exactly those mesh chunks.

The one thing this adapter must get right: never reparent ARKit’s mesh chunks. ARMeshManager writes each chunk’s local pose against XROrigin.TrackablesParent every frame (including relocalization drift correction). Reparenting a chunk elsewhere leaves every later pose update silently stale, and the mesh drifts. The correct root is XROrigin.TrackablesParent itself. Don’t create a new parent and move meshes into it.

This repo’s reference implementation is ARMeshSource (Assets/MeshBasedAnchoring/ARFoundationBridge/ARMeshSource.cs). Its Root property returns XROrigin.TrackablesParent directly and its MeshCount/Meshes track what’s currently live. Hand ARMeshSource.Root (or your own equivalent) to the flow components below exactly like you’d hand them any other scanned-room Transform.

Wiring the flow

Add one GameObject with an AnchorCreationFlow and a LocalizationFlow component. Both auto-add the MultiRoomAnchoring component they need if you don’t add it yourself: one shared instance is fine, and both flows read and write the same rooms/client fields at different times.

// Placing side
creationFlow.Configure(meshSource.Root, anchorPointTransform, "living-room");
creationFlow.StartScanning();
// ... later, once creationFlow.State reaches ReadyToPlace (or even during Scanning):
AnchorBatch batch = null;
creationFlow.OnPlaced.AddListener(b => batch = b);
await creationFlow.PlaceAnchorAsync();

// Finding side (fresh app instance/session)
localizationFlow.Anchoring.InstallBatches(loadedBatches); // from AnchorPayloadPersistence, below
localizationFlow.SetVisualization(myMarkerVisualization); // optional; text-only if you skip this
localizationFlow.OnResult.AddListener(HandleResult);
localizationFlow.StartLocalizing();

If you only ever have one room and one anchor, ManualAnchoring is a simpler direct entry point (see Manual anchoring). AnchorCreationFlow/LocalizationFlow are state-machine wrappers over MultiRoomAnchoring. They exist specifically because most real apps want the scanning-strength meter and the Matched/Ambiguous/NoMatch retry loop for free.

PlaceAnchorAsync()/the flows’ internal extraction calls run the CPU-heavy work off the main thread. Never call .Result/.Wait()/.GetAwaiter().GetResult() on any of these Tasks from Unity’s main thread. That deadlocks the app the moment the awaited work needs to resume on the main thread, which this code does.

License key

Licensing is fully offline once a key is installed. Request one from Window > LansAnchor > License in the Editor, see Licensing for the trial flow. At runtime, call LicenseInfo.Resolve() if you want to show your own status line (key type, expiry). Trial validity is expiry-only, so there is no use count worth showing. Worth knowing plainly: license enforcement in this SDK is currently editor-only. A built Player never blocks registration on license state, regardless of what you pass to RegistrationParams.RequireLicense. A runtime status panel is informational, not a gate, today.

Core flows

Place and save an anchor

AnchorCreationFlow re-extracts features from the live mesh a few times a second while scanning. It reports an AnchorStrength (Weak/Ok/Strong + a one-line hint) via OnStrengthChanged. Wire that straight to a status label. PlaceAnchor()/PlaceAnchorAsync() builds the anchor from whatever’s scanned so far and returns an AnchorBatch via OnPlaced.

Persist it with the bridge’s own file helper:

string path = Path.Combine(Application.persistentDataPath, "my-anchor.bin");
AnchorPayloadPersistence.SaveToFile(batch, path);

AnchorBatch.ToBytes()/FromBytes() is what actually gets written: a stable binary format, not JSON. That’s the byte payload you hand to your own transport.

Share the payload

There’s no cloud step and no bundled cross-platform share-sheet API in the package today. You send the saved file (or batch.ToBytes() in memory) over whatever channel your app already has: AirDrop, your own server, a QR-code-triggered download, Wi-Fi Direct. Real-device measurements on this project’s own corpus (118 real anchor batches, one member each): median 1.93 MB, up to 5.55 MB. Budget for a few megabytes, not kilobytes. A single-anchor batch at the default 5 m scan radius routinely runs past 1 MB on a dense real scan. A batch that also carries the optional room-wide tier (recommended, see below) adds up to roughly another 400 KB-1 MB. AirDrop and Wi-Fi Direct-class transports handle this fine. A plain QR code does not: a mini/low-feature anchor tier for QR-range payloads is on this project’s backlog, not built yet.

Find an anchor from a fresh scan

On the finding device, load the saved batch and hand it to MultiRoomAnchoring (directly, or via LocalizationFlow, which also gives you retry/backoff for free):

AnchorPayloadPersistence.TryLoadBatchFromFile(path, out AnchorBatch batch);
localizationFlow.Anchoring.InstallBatches(new List<AnchorBatch> { batch });
localizationFlow.Anchoring.SetClient(meshSource.Root);
localizationFlow.StartLocalizing();

OnResult fires with a MultiRoomRegistrationResult whose State is one of:

  • Matched: found, with a Transform you can apply to your scene and WinningAnchorWorldPoses() giving the anchor’s position in your local frame.
  • Ambiguous: two or more candidates scored too close to call. Ask the user to move/scan more, then retry (LocalizationFlow does this automatically up to a configurable retry count).
  • NoMatch: nothing passed verification. NoMatchReasons() gives a plain-language reason per room, such as “insufficient shared geometry with this room” or “floor level did not match this room”. It is safe to show directly in your UI.

Matched isn’t binary in practice. A small band of results clear every verification gate but are still measurably more likely to be wrong than the rest of the Matched population. That is a known, measured property of the corpus this project calibrated against, not a bug in your setup. Rather than hide that, classify every Matched result through a certainty tier before you show it as fact:

CertaintyTier tier = CertaintyTierClassifier.Classify(result.State, diagnostics.Certainty);
// Rejected: not Matched, nothing to show.
// Tentative: Matched, but in the band where wrong results are known to survive every gate.
// Confident: Matched, comfortably clear of that band.

Treat Tentative as “probably right, but ask.” Show the anchor with a visibly hedged presentation, rather than the normal, committed one. This repo’s sample uses a lower-alpha “ghost” beacon and a same-frame “is this the right spot?” prompt. Never silently promote a Tentative result to a plain confident-looking marker: that is the exact failure mode this tier exists to surface. If your scan improves (more coverage, a second walk-through), a retry commonly resolves Tentative into Confident.

Multi-room batches

Nothing changes about the flow above to support several known rooms. AnchorBatch is already a per-room unit keyed by a caller-assigned RoomId, and MultiRoomAnchoring/LocalizationFlow already evaluate a list of them:

var rooms = new List<RoomSourceEntry> {
    new RoomSourceEntry { RoomId = "kitchen", SourceRoot = kitchenRoot, Anchor = kitchenAnchor },
    new RoomSourceEntry { RoomId = "office", SourceRoot = officeRoot, Anchor = officeAnchor },
};
anchoring.SetRooms(rooms);
var batches = await anchoring.BuildBatchesAsync(); // one AnchorBatch per room

or load several previously-saved batches from disk and InstallBatches(list) them directly. The client scan is checked against every supplied room in one call, and the result tells you which room (if any) matched. See Multi-room anchoring for the full picture.

Practical guidance

Scan generously around the anchor spot before placing it, not just the anchor point itself. The strength meter (AnchorStrength) is a coverage/richness proxy, not a success prediction. A spot can score Strong on its own geometry and still fail once a real second scan tries to match it. More scanned area around the anchor, especially corners/edges/furniture rather than a single flat wall, measurably improves the odds.

The finder only needs to scan the anchor’s neighborhood, not the whole room. Registration matches local geometry around the anchor point. Walking the client device around the immediate area the anchor was placed in is enough, and is usually faster than a full-room re-scan.

An AR session reset creates a new, independent coordinate frame. Every time your app calls ARSession.Reset() (a “New Scan” button, typically), ARKit’s tracking origin changes. Anchors saved before that reset and anchors saved after it are in two different local coordinate spaces on that device. Nothing in the payload format says so. There’s no “same session” check built in. If your app supports resetting tracking, either don’t let a user localize against an anchor placed in a since-reset session, or track your own session/epoch marker and refuse the cross-frame case explicitly. Getting this wrong doesn’t look like a crash. It looks like a confident, wrong anchor position.

Expect Tentative on some re-scans, and design for it rather than around it. It’s not a failure state to hide. Show the hedged presentation described above, and let the user either accept it or scan a bit more and retry. Auto-retrying once or twice (LocalizationFlow’s built-in backoff) resolves a meaningful fraction of Tentative/Ambiguous results into a clean Confident Match. The user does not have to do anything extra.

Troubleshooting

Where to look first: the always-on session log. If you’re using FileLogger (Assets/MeshBasedAnchoring/ARFoundationBridge/FileLogger.cs, hooks Application.logMessageReceived), every session writes a size-capped log. It goes to Application.persistentDataPath/Logs/Session_<timestamp>.log. This is the log that’s actually readable off a real device build. Note: ManualAnchoring’s own per-run registration summary (FeatureRegistration_<timestamp>.log) is written under Application.streamingAssetsPath. That path is writable in the Editor but is typically read-only on a real device build (iOS/Android bundle their StreamingAssets read-only). Don’t rely on finding that file on-device. Use the session log instead, or route your own diagnostics through FileLogger’s mechanism.

Reading a NoMatch/Ambiguous result. Don’t guess from the state alone. result.NoMatchReasons() and each candidate’s FailureReason() give you the first verification gate that actually failed, in plain language, per room. These strings never mention overlap ratios, RMSE, or ICP. They’re meant to go straight into your UI.

A good reference for the Matched/Tentative/Rejected UI split: this repo’s sample pairs an unmissable beacon (solid for Confident, a lower-alpha “ghost” for Tentative, hidden for anything else) with a same-screen “is it in the right spot?” prompt. That prompt shows immediately after any find that displayed something. See AnchorBeacon.cs and FindVerdictPrompt.cs in Assets/MeshBasedAnchoring/ARFoundationBridge/ if you want to mirror that pattern rather than build your own from scratch.

Next