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.
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.unitybridgeinstalled. 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(withARCameraManager+ARCameraBackgroundon its camera). - An
ARMeshManager, added as a child of theXR Origin, with a mesh prefab assigned. A plainGameObjectwith aMeshFilter+MeshRendereris enough. The shipped sample also adds a wireframe-rendering component, which is cosmetic and not required for registration. The sample runsARMeshManager’sDensityat0.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:
- Subscribes to
ARMeshManager.meshesChanged. - Tracks the resulting
MeshFilters. - Exposes a stable root
Transformwhose 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
Transformyou can apply to your scene andWinningAnchorWorldPoses()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 (
LocalizationFlowdoes 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 roomor 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
- Single room, single anchor without the flow wrappers: Manual anchoring.
- Several rooms in one client scan: Multi-room anchoring.
- The finished reference app this page’s flow is modeled on: AR Foundation sample.
- Reading verification gates and confidence in depth: Diagnostics.