Skip to content

The scene graph convention

S edited this page Aug 20, 2026 · 3 revisions

The scene-graph convention

There is one structural fact in this framework that, once understood, answers a surprising number of "why is my object behaving like that" questions — and it is the opposite of what most people assume on first reading.

The scene root is world space. The AR tracking frame is a group inside it.

The layout

scene                          ← GPS-aligned (NUE) metres — the WORLD frame
├── arWorldGroup               ← carries the GPS→AR alignment matrix
│   ├── camera                 ← the WebXR viewer pose
│   └── ar-content             ← anything fixed in AR space
└── ..objects at GPS coords..  ← waypoints, POIs, navigation arrows

Most AR frameworks put the tracking frame at the root and hang world content off a child. This is the other way round, and the inversion is deliberate: the thing that is known — a latitude and longitude converted to metres — sits at the root, and the thing that is estimated — where the AR session's tracking frame sits in the world — is the transform on the group.

When the solver produces a new alignment, the framework writes it to arWorldGroup.matrix, smoothly interpolated. The camera lives inside that group, so the camera moves. Objects parented directly to the scene do not.

The counter-intuitive consequence

Ask the obvious question: where do I put a waypoint that must stay at its GPS coordinate?

The obvious answer is "the scene root, at its NUE coordinates", and it is half right. The object's world pose is then correct forever — it never moves, because the number never changes. But every time the alignment is corrected the camera shifts inside arWorldGroup, and from the user's point of view a stationary object with a moving camera is indistinguishable from a moving object. The waypoint appears to float.

That is the trap. Being correct in world coordinates and looking correct on screen are different properties, and the naive parenting gets you the first at the cost of the second.

So there are three placements, not two:

  • Under the scene root, at NUE coordinates. World pose exact and permanent; visually floats during corrections. Cheap and right for small markers where a little swimming does not matter — reference-point spheres, bulk map pins.
  • Inside arWorldGroup, at a fixed local transform. Welded to the AR-tracked world, so it stays visually nailed to the spot for the whole session. Its world/GPS pose drifts as alignment is corrected, so it cannot be replayed or shared by coordinate. This is the right home for user-created local content: a trail of the path they walked, a hit-test reticle, a decoration they placed by hand on a particular bench.
  • createGpsAnchor, inside arWorldGroup. The best of both, and the reason the helper exists: the object rides the group with the camera, so it is visually stable, and its local position is re-derived every tick from its GPS target and the current alignment, so it stays at its real coordinate. The factory throws if you hand it an object that is not a descendant of the group — placing an anchor under the scene root defeats the stability it exists to provide.

The symptoms, mapped back

  • "My hand-placed decoration keeps sliding off the bench." It is a GPS anchor when it should be a fixed local object inside arWorldGroup. The user aimed it; corrections are moving it away from where they aimed.
  • "My waypoint looks like it is swimming as I walk." It is parented to the scene root, so the camera is moving under corrections while it stands still. Make it a GPS anchor.
  • "My waypoint was fine, then wrong after a long walk." It is inside arWorldGroup at a fixed local transform, so it has been drifting with the tracking frame all along.
  • "Everything is in the right place but the whole scene is rotated." The alignment's heading estimate has not converged. Walk a few metres.

Why a convention rather than an API

The framework could have offered addWorldObject() and addLocalObject() and hidden the graph. It exposes the graph instead, because you are writing a three.js app: you already know how to parent, group, traverse, raycast and dispose, and wrapping that in a bespoke object API would mean re-exposing all of three.js through a narrower door.

One caveat worth knowing: the exported SCENE_NODE constants cover the framework's own internal nodes, not every node named in this article. Reach the live graph through getScene(), getArWorldGroup(), getCamera() and getCurrentArPose().

The rule of thumb

Ask what the object's truth is, and then ask whether it must also look fixed:

  • A coordinate on Earth, and it must look nailed down → createGpsAnchor, and see how it corrects itself.
  • A coordinate on Earth, and a little swimming is acceptable → scene root.
  • A position the user chose in this session → fixed transform inside arWorldGroup.
  • A position on the screen → the camera.

Clone this wiki locally