Navigation
AR indoor wayfinding on top of VPS localization, with pathfinding and an arrowed floor path
Pick a destination, follow an arrowed path along the floor, arrive. Navigation runs on top of VPS localization and works with both ThreeAdapter and NeedleAdapter.
npm install @multisetai/vps three-pathfindingthree-pathfinding is an optional peer dependency, loaded on demand. Install it only if you use NavMeshPathfinder, which you almost certainly will.
A complete working app is available as the Needle navigation sample: a Unity project plus web folder with the components, visuals, and UI already wired. Clone it, add your credentials and map code, and press play. Everything on this page is also written to stand on its own, so you can build from scratch instead.
What the SDK provides
The @multisetai/vps/navigation entry point has everything needed to build a navigation app. It is not a navigation app itself.
Navigation
The state machine: recalculation cadence, off-navmesh grace period, arrival detection, events, and distance queries
NavMeshPathfinder
Pathfinding over a navmesh, backed by three-pathfinding
StraightLinePathfinder
Development stub that walks through walls. Useful before a navmesh exists.
buildPathRibbon(corners, options)
Converts path corners into ribbon triangles you can render
IMapPOI, IPathfinder, MapPOIType, event and state types
Contracts
Deliberately not included: UI, CSS, shaders, materials, label design, POI icons, and debug overlays. Those are design decisions, so you own them. Nothing needs a hook or a configuration flag:
const mesh = new THREE.Mesh(buildPathRibbon(corners), yourMaterial);
navigation.on('pathUpdated', ({ corners }) => {
mesh.geometry.dispose();
mesh.geometry = buildPathRibbon(corners);
mesh.visible = corners.length > 1;
});Requirements
A MapSpace frame. Navigation computes every route in map space, which is what makes relocalization free.
A navmesh: any triangulated walkable surface, expressed in map space.
One or more destinations, as
IMapPOIobjects.
MapSpace is required, not optional. It defines the coordinate frame that navigation works in. A scene using only MapAnchor cannot run navigation.
Creating the navmesh
Navigation reads triangles, so any triangulated surface works. Unity's runtime NavMesh is not available on the web, so generate the mesh ahead of time.
The quickest route is the Recast browser tool at navmesh.isaacmason.com:
Download your map's 3D Mesh (.glb) from the developer portal. Take the raw mesh, not the textured one, because pathfinding only reads geometry and textures add tens of megabytes.
Drag the file into the tool and set the generation config. Note that Walkable Radius, Climb, and Height are expressed in voxels, which are multiples of Cell Size and Cell Height, so the numbers look small. For a typical indoor scan: Cell Size
0.10, Cell Height0.10, Walkable Slope Angle60, Walkable Height8, Walkable Climb3, Walkable Radius1, Min Region Area2.Click Generate NavMesh. Teal polygons should appear over the floor.
Test connectivity before exporting. Enable Test Agent under Display Options, right-click to place the agent in one room, then left-click a target in another. If the agent walks there, the navmesh is sound. If it refuses, the surface is torn and nothing downstream will fix it, so raise Cell Size and regenerate.
Click Export as GLB. Do not use Export as Recast NavMesh, which is Recast's own binary format that neither Unity nor this SDK reads.
Verify the exported file before using it. The tool's GLB export is unreliable and can produce a file that looks plausible but that no viewer will load.
Open it in a glTF viewer such as gltf-viewer.donmccurdy.com and confirm the geometry appears. If it fails to load, use Export as GLTF instead, open the Three.js editor, drag the .gltf file in, then choose File, Export GLB. That round trip only re-serialises the file, so geometry and coordinates are unchanged.
A file of only a few bytes means nothing was generated. Press Generate NavMesh first, because export writes out the last generated result.
Plain Three.js
Unity and Needle Engine
Navigation needs three things in the scene.
1. Scene structure
Your navigation component must sit outside the MapSpace subtree. MapSpace.hideUntilLocalized hides its subtree until the first localization, Needle treats an invisible GameObject as inactive, and an inactive component never runs update(). Nested there, navigation would never start.
Keep both MapSpace and the navmesh at identity: position 0,0,0, no rotation, scale 1. The navmesh is already in map coordinates because it was generated from the map mesh, so any transform on it applies the offset a second time. A displaced navmesh is the most common setup mistake.
2. Import the navmesh
Drag the GLB into your Unity project, then drag that asset from the Project window into the Hierarchy as a child of MapSpace. Those are two separate actions, and only the second puts the mesh in the scene. Rename it and zero its Transform.
3. A navigation component
Create this in your web project's src/scripts/ folder. Needle generates a Unity Inspector component from it.
Setup must not run in start(). MultisetVPS authorizes asynchronously, so its adapter is still null when start() runs on other components. Polling in update() as shown above is the simplest correct approach.
Public methods such as navigateToNearest() and stopNavigation() appear in a Unity Button's On Click dropdown, so you can drive navigation without writing UI code.
UI must be HTML, not a Unity Canvas. The SDK owns the WebXR session, so Needle's UI raycaster never activates, and in immersive-ar the WebGL canvas receives no pointer events. The session requests the dom-overlay feature, which does receive taps. Mount your UI in adapter.getSession().getOverlayRoot(). A world-space Canvas still renders correctly for signage, but it cannot be tapped during a session.
Navigation API
Navigation.create(options)
Promise<Navigation>
Static. Builds and attaches to the adapter.
setDestination(target)
void
Accepts an IMapPOI, a registered POI id, or a bare map coordinate. Emits unreachable and does not start if no route exists.
stop()
void
Stop navigating and clear the path.
recalculate()
void
Force an immediate recalculation, ignoring the interval and movement threshold.
state
NavigationState
'unlocalized', 'idle', 'navigating', 'off-navmesh', or 'arrived'.
destination
IMapPOI or null
The active destination.
currentPath
readonly THREE.Vector3[]
Path corners in map space. Empty when not navigating.
remainingDistance
number
Metres left along the current path.
pois
readonly IMapPOI[]
All registered destinations.
setPOIs(list), addPOI(poi), removePOI(id), getPOI(id)
Manage destinations at runtime. Removing the active destination stops navigation.
distanceTo(poi)
number
Walking distance in metres, or -1 when unknown. Cached and throttled, so it is safe to call every frame.
isReachable(poi)
boolean
Whether a complete route exists.
nearestPOI()
IMapPOI or null
Closest destination by walking distance, skipping unreachable ones.
getViewerMapPosition(target?)
THREE.Vector3 or null
The viewer's position in map space, or null before the first localization.
on(event, fn)
() => void
Subscribe. Returns an unsubscribe function.
attach(), detach()
void
Subscribe to or unsubscribe from the adapter. create() attaches for you.
update(deltaSeconds)
void
Advance manually. Only needed if you drive your own loop.
dispose()
void
Detach and release everything.
Navigation.pathLength(corners)
number
Static. Summed distance between consecutive corners.
Events
stateChanged
{ state, previous }
destinationChanged
IMapPOI or null
pathUpdated
{ corners, remainingDistance }
arrived
IMapPOI
unreachable
IMapPOI, when no complete route exists
tick
{ deltaSeconds }, every frame
The tick event fires from the XR frame loop during a session and from requestAnimationFrame outside one, so animation and UI can be developed and tested on a desktop before putting a phone in AR.
NavMeshPathfinder
NavMeshPathfinder.fromObject3D(object, options?)
Promise<NavMeshPathfinder>
Static. Merges every descendant mesh and transforms it into options.space.
NavMeshPathfinder.fromGeometry(geometry, options?)
Promise<NavMeshPathfinder>
Static. From geometry already in map space.
findPath(from, to)
THREE.Vector3[] or null
Corners including both endpoints. null means no complete route, because a partial path is never returned as success.
clampToNavMesh(p, maxDistance?)
THREE.Vector3 or null
The viewer's projection onto the walkable surface.
snapDestination(p, label?)
THREE.Vector3 or null
A destination's projection onto the walkable surface.
geometry
THREE.BufferGeometry
The merged navmesh in map space. Use it to build a debug overlay.
groupCount
number
The number of disconnected walkable regions.
dispose()
void
Release the zone data.
Options
space
the source object
The space to express navmesh geometry in. Pass mapSpace.object.
weldTolerance
1e-4
Vertex welding tolerance during zone construction.
destinationSnapRadius
1
How far a destination may sit off the walkable surface horizontally, in metres.
destinationSnapWarnHeight
3
Log a warning once when snapping moves a destination further than this vertically.
startRegionTolerance
1
How much further than the closest walkable point a disconnected region may be and still count as the viewer's location.
Destination height does not matter
A destination's Y value is almost always an authoring accident: dropped at zero, left at eye level, or placed above a counter. Its horizontal position is the real information. Destinations are therefore projected onto the walkable surface with no height limit at all, and only the horizontal offset is checked against destinationSnapRadius.
When several surfaces qualify, the closest one at or below the destination wins, and a surface above is used only when nothing is below. That rule is what makes stacked floors behave, because a destination authored above a floor belongs to that floor rather than the one overhead.
destinationSnapRadius is deliberately small. A thin wall plus the navmesh's own erosion from walls is roughly 0.8 m, so a larger radius could snap a destination through a wall into the next room. A destination silently bound to the wrong room is worse than a clear "no route" that tells you to move it.
The viewer's projection uses a different rule, because for the viewer height is real information: it is how one storey is told from another. IPathfinder exposes snapDestination as optional for this reason, and a custom pathfinder that omits it falls back to clampToNavMesh.
Drawing the path
buildPathRibbon converts corners into triangles. It is the only rendering code in the SDK, because it is the only part with no design content and a single correct answer.
width
0.35
Ribbon width in metres
heightAboveFloor
0.1
Lift above the walkable surface. Raise it if the ribbon flickers against the floor.
cornerRadius
0.4
Radius used to round off interior corners. 0 gives sharp corners.
cornerSegments
4
Points per rounded corner. Higher is smoother and costs triangles.
miterLimit
3
Cap on how far a sharp joint may extend, as a multiple of half the width.
The vertex contract
This is stable API, so you can write a shader against it:
uv.xis the distance along the path in metres: cumulative, horizontal, and not normaliseduv.yis0to1across the ribbon widthtwo vertices per corner, one quad per segment, indexed, with no normals
Metres rather than a normalised range keeps a pattern's real-world size constant whatever the path length, and lets it flow unbroken across corners instead of restarting per segment. Horizontal distance rather than 3D means a path climbing a ramp does not stretch its pattern.
Corners are rounded by default
At a sharp corner the two vertices are offset along the miter, which is the angle bisector, rather than perpendicular to either segment. That makes the quad a trapezoid, so any pattern mapped onto it is sheared by up to half the turn angle, roughly 45 degrees on a right-angle turn. Arrows visibly bend. Rounding spreads the turn over a short arc, which reduces the worst shear from 45 degrees to about 76 degrees, where 90 degrees means no shear at all.
The radius is clamped per corner to 40 percent of the shorter adjacent segment, so tight zig-zags degrade sensibly instead of folding the ribbon back on itself. Endpoints are never moved. Note that a rounded path is slightly shorter than a sharp one, because corners are cut, so remainingDistance drops by a few centimetres per corner.
Tiling an arrow texture
Arrow size and arrow spacing must be separate controls. Mapping one texture repeat across the whole spacing interval is the obvious shortcut, and it stretches each arrow by spacing divided by width, which for a 0.35 m ribbon at 2 m spacing is 5.7 times. Map the texture over an explicit arrow length and leave the rest of the interval empty:
Default the arrow length to the ribbon width and a square texture comes out undistorted. The sample has a working implementation of this shader in NavigationVisuals.ts.
Arrow art is conventionally a silhouette in the alpha channel with flat RGB. Treat the texture as a mask and take the colour from a uniform, because multiplying by the texture's RGB gives black arrows whatever colour you set.
Advance uScrollOffset by speed * deltaSeconds each frame and wrap it on uArrowSpacing. Accumulating distance rather than elapsed time is what lets the value wrap exactly on one arrow repeat at any speed.
Tuning
recalcIntervalMs
500
How often the route is recomputed. Fires regardless of whether the viewer has moved.
repathMoveThreshold
0.5
Metres of movement that force an extra immediate recalculation.
invalidPathGraceMs
10000
How long the viewer may be off the walkable surface before navigation gives up.
arrivalRadius
1.5
Metres that count as arrival, measured horizontally.
navMeshSnapDistance
4
How far to search for walkable ground under the viewer. Also separates floors.
Two of these are less obvious than they look. The interval fires regardless of movement, because recalculating only while moving cannot notice a route breaking while the user stands still. And arrival is measured horizontally, because a 3D distance includes eye height: a destination at floor level sits about 1.6 m below the camera, so a 1.5 m radius would be unreachable from any standing position.
Troubleshooting
Call diagnose() first. It names the blocker in one word.
ok
Navigation is ready
no-navmesh
No pathfinder, or the navmesh produced no walkable surface
not-localized
The first localization has not succeeded yet
off-navmesh
The viewer is not within navMeshSnapDistance of the walkable surface
no-pois
No destinations registered
pois-off-navmesh
Every destination failed to project onto the surface. Usually the navmesh covers a different area than the destinations, or they were authored in a different frame.
Content is misaligned after localizing
MapSpace or the navmesh has a stray Transform. Both must be at identity. Enable showMesh on the adapter to compare the scanned map against your content.
Every destination reports no route
Enable a navmesh debug overlay built from pathfinder.geometry and check the surface is where you expect. Also check groupCount.
One destination reports no route, others work
It is more than destinationSnapRadius outside the walkable area horizontally. Height is forgiven, sideways placement is not.
The route disappears while walking
The navmesh is fragmented, so the viewer's projection flips between disconnected regions. Regenerate with a larger Cell Size and Min Region Area around 2.
groupCount is greater than 1 on one floor
The navmesh is torn. The usual cause is a T-junction, where a vertex touches another triangle's edge without being shared with it. Two triangles need two shared vertices to count as neighbours, so the surface looks solid but is not.
The path never appears, even after localizing
The path mesh is a child of MapSpace, which stays hidden until the first localization succeeds. Confirm localization actually succeeded.
Arrows look stretched
Arrow size is being derived from spacing. Give the shader an explicit arrow length.
Arrows look bent at turns
Raise cornerRadius, or raise cornerSegments for a smoother arc.
The path flickers against the floor
Raise heightAboveFloor.
Last updated
Was this helpful?

