@relu-interactives/spatial-ecs 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,9 +7,34 @@ With passthrough on, the experience composites over the user's real room instead
7
7
  of a virtual background. Turn it on in the inspector (XR Rig → Mixed Reality), or
8
8
  from a script by authoring the rig.
9
9
 
10
- Mixed reality is a **Quest and Android XR** capability. Safari on visionOS grants
11
- `immersive-ar` to no web page at all, so a passthrough project falls back to an
12
- ordinary VR session there rather than failing to start.
10
+ Mixed reality passthrough is a **Quest and Android XR** capability.
11
+
12
+ ## Apple Vision Pro
13
+
14
+ Safari on visionOS — checked through visionOS 27 — supports WebXR
15
+ `immersive-vr` only. It grants `immersive-ar` to no web page, and exposes no
16
+ plane detection, mesh detection or depth sensing. This is a platform policy,
17
+ not a missing feature on our side; every web XR tool works within it (the
18
+ industry-standard answer is a VR fallback mode).
19
+
20
+ A mixed-reality project still works there, three ways:
21
+
22
+ 1. **Automatic VR fallback.** The session probe falls back to `immersive-vr`,
23
+ the authored background applies, and hands/pinch interaction works. The
24
+ entry page says so honestly: "Passthrough isn't available in this browser —
25
+ this experience will run in VR."
26
+ 2. **A synthetic floor, no scan needed.** With `environmentPhysics` on and no
27
+ scene understanding granted at all, the engine synthesizes one floor
28
+ collider whose top face sits at y = 0 — which `local-floor` makes the
29
+ user's REAL floor. Dropped objects land at floor height instead of falling
30
+ forever. (This only happens when the detection features are entirely
31
+ absent; a Quest that merely has not scanned yet keeps its room-capture
32
+ prompt instead.)
33
+ 3. **"View in your room" via AR Quick Look.** On Apple devices the entry page
34
+ offers a native placement preview: the scene's content is exported to
35
+ `.usdz` on the fly and handed to AR Quick Look, which places it in the real
36
+ room with passthrough — Apple's sanctioned web-AR path (it also works on
37
+ iPhone and iPad). This is a static snapshot: no scripts, no physics.
13
38
 
14
39
  ## What the rig controls
15
40
 
@@ -41,6 +66,87 @@ Each detected surface becomes an ordinary entity, so it goes through the same
41
66
  physics as everything else: a dynamic rigidbody dropped in a mixed-reality scene
42
67
  lands on the real floor with no extra code.
43
68
 
69
+ ### The room has to have been scanned first
70
+
71
+ This is the single most common reason a mixed-reality scene comes up empty, and
72
+ it is not a bug in the experience.
73
+
74
+ **Plane and mesh detection do not scan anything.** They replay a scan the user
75
+ captured earlier through the headset's own room setup — on Quest, *Settings >
76
+ Guardian > Mixed Reality* (Space Setup). If the user has never run it,
77
+ `detectedPlanes` and `detectedMeshes` simply stay empty, with no prompt and no
78
+ error. If they ran it in a **different room**, you get that room's furniture
79
+ overlaid on this one, which looks like the tracking is broken.
80
+
81
+ `XREnvironmentSystem` handles this: when `environmentPhysics` is on and nothing
82
+ at all has been detected three seconds into the session, it calls
83
+ `XRSession.initiateRoomCapture()`, which launches room setup from inside the
84
+ session. It is a Meta extension, so it is feature-detected and silently skipped
85
+ elsewhere, and — per Meta's contract — it is only ever called **once per
86
+ session**.
87
+
88
+ If a user reports "it never asked to scan my room" and the geometry does not
89
+ match, they are almost certainly running against a stale scan from another
90
+ space; re-running Space Setup for the current room fixes it.
91
+
92
+ ### Reading the `environmentDebug` overlay
93
+
94
+ The overlay colours surfaces by what the runtime says they are, so a glance
95
+ (or a screenshot) tells you which surface is wrong rather than just "wireframe
96
+ somewhere":
97
+
98
+ - **green** — the floor plane
99
+ - **blue** — walls
100
+ - **purple** — the ceiling
101
+ - **orange** — labelled things: tables, couches, shelves, doors, windows
102
+ - **faint gray** — the raw room scan (the "global mesh")
103
+
104
+ Two things about the global mesh are easy to misread. It is deliberately
105
+ COARSE — a couch becomes a melted lump, a wall gets bumps — because it is a
106
+ collision mesh, not a picture; those lumps are not phantom objects. And under
107
+ `environmentPhysics: "auto"` it is what the physics uses, with the flat planes
108
+ skipped entirely, so orange furniture boxes and gray scan lumps can legitimately
109
+ overlap the same real object.
110
+
111
+ The overlay is depth-tested like ordinary geometry — the near wall hides the
112
+ far one — but it never *writes* depth, so it cannot occlude actual content. (It
113
+ was briefly rendered X-ray; on device, the dense global scan at low opacity
114
+ fuses into translucent sheets, and without a depth test the walls' sheets drew
115
+ through everything and stacked in front of the user's face.)
116
+
117
+ For identifying a mystery shape, `XREnvironmentSystem.getDebugSnapshot(world)`
118
+ returns a census of every spawned surface — kind, semantic label, position and
119
+ approximate radius.
120
+
121
+ One artifact is handled for you: three.js's depth-occlusion pass draws a
122
+ head-locked mesh that writes only depth, but leaves its colour output
123
+ undefined — on Quest hardware that painted the depth camera's view over the
124
+ screen as faint contour bands that follow the head. `XRSessionSystem` forces
125
+ `colorWrite` off on that mesh every frame, so enabling `depthOcclusion` costs
126
+ no visual artifacts.
127
+
128
+ ### Lost tracking freezes a surface — it never follows the head
129
+
130
+ When the headset loses tracking of a surface, WebXR does not stop reporting a
131
+ pose — it reports an *emulated* one: the last known pose held relative to the
132
+ viewer, i.e. glued to the head. Applying that to room geometry would make the
133
+ surface — and its collider — follow the user around, shoving physics objects
134
+ out of the way. The engine instead freezes the surface at its last tracked
135
+ pose, resumes when tracking returns, and never creates a surface from an
136
+ emulated pose in the first place. The diagnostics census marks such surfaces
137
+ with `emulated: true`.
138
+
139
+ ### No artificial locomotion in passthrough
140
+
141
+ While a session composites over the real world, `XRLocomotionSystem` disables
142
+ ALL artificial locomotion — snap turn, teleport and smooth movement — whatever
143
+ the rig says. Virtual content in mixed reality is registered to the real room;
144
+ rotating or translating the rig swings every virtual object out of alignment
145
+ while the passthrough room stays put, which feels broken rather than like
146
+ movement. The user moves by walking. Note that `snapTurn` is a separate field
147
+ from `locomotion`, so `locomotion: "none"` alone would NOT have stopped the
148
+ stick from turning the rig — the gate is on the live blend mode.
149
+
44
150
  ## Finding the real room from a script
45
151
 
46
152
  Detected surfaces carry `XREnvironmentComponent`, so they are queryable like any
@@ -93,6 +199,36 @@ export default class RoomAware extends Behaviour {
93
199
  }
94
200
  ```
95
201
 
202
+ ## Hit-testing the real room from a script
203
+
204
+ With `hitTest` on the rig, `XRHitTestSystem` keeps a ray from the viewer (and
205
+ one per hand) cast against the real room. Systems are resolved by INSTANCE, so
206
+ pass a throwaway one to `getSystem`:
207
+
208
+ ```ts
209
+ import { Behaviour, XRHitTestSystem } from "@relu-interactives/spatial-ecs";
210
+ import { TransformComponent } from "@relu-interactives/spatial-ecs/components";
211
+
212
+ export default class PlaceOnRealSurface extends Behaviour {
213
+ onSelectStart() {
214
+ const hitTest = this.world.getSystem(new XRHitTestSystem());
215
+ // "viewer" = where the user is looking; "left"/"right" = hand rays.
216
+ const hit = hitTest.getHitResult("viewer");
217
+ if (!hit) return; // nothing granted, or no surface under the ray yet
218
+
219
+ const transform = this.world.getComponent(this.entityId, TransformComponent);
220
+ if (transform) {
221
+ transform.position.x = hit.position.x;
222
+ transform.position.y = hit.position.y;
223
+ transform.position.z = hit.position.z;
224
+ }
225
+ }
226
+ }
227
+ ```
228
+
229
+ `getHitResult` returns null until the runtime grants hit-testing and the ray
230
+ actually meets a surface — always handle that case.
231
+
96
232
  ## Anchors
97
233
 
98
234
  A headset keeps revising where it thinks the room is. Content at fixed scene
@@ -106,6 +242,27 @@ on, and it stays put relative to the room.
106
242
  Because the anchor owns the transform, moving the entity by script or physics has
107
243
  no lasting effect — disable the component first, move it, then re-enable.
108
244
 
245
+ From a script the toggle is the whole API:
246
+
247
+ ```ts
248
+ import { Behaviour } from "@relu-interactives/spatial-ecs";
249
+ import { XRAnchorComponent } from "@relu-interactives/spatial-ecs/components";
250
+
251
+ export default class ReleaseOnGrab extends Behaviour {
252
+ onGrab() {
253
+ // Let the hand move it: release the weld for the duration of the grab.
254
+ const anchor = this.world.getComponent(this.entityId, XRAnchorComponent);
255
+ if (anchor) anchor.enabled = false;
256
+ }
257
+
258
+ onRelease() {
259
+ // Re-anchor wherever the user left it.
260
+ const anchor = this.world.getComponent(this.entityId, XRAnchorComponent);
261
+ if (anchor) anchor.enabled = true;
262
+ }
263
+ }
264
+ ```
265
+
109
266
  Anchors last for the session only. Persisting one across launches needs
110
267
  `requestPersistentHandle`, which is a draft extension and not supported here.
111
268
 
@@ -185,6 +185,35 @@ onXRSessionStart(capabilities: XrCapabilities) {
185
185
 
186
186
  Two rules worth internalising: **move the rig, never the camera** (the headset owns the camera while presenting and overwrites it every frame), and give any destructive gesture a longer **Hold Time** so it cannot fire in passing.
187
187
 
188
+ ### Reading grab state from a script
189
+
190
+ `XRInteractionSystem.getGrabInfo(entityId)` reports who is holding an entity —
191
+ hand count, primary/secondary handedness, live hand separation (two-hand grabs)
192
+ and the attach-point ids in use. Systems resolve by instance:
193
+
194
+ ```ts
195
+ import {
196
+ Behaviour,
197
+ XRInputSystem,
198
+ XRInteractionSystem,
199
+ } from "@relu-interactives/spatial-ecs";
200
+
201
+ export default class TwoHandChargeUp extends Behaviour {
202
+ onUpdate() {
203
+ const interaction = this.world.getSystem(
204
+ new XRInteractionSystem(new XRInputSystem()),
205
+ );
206
+ const grab = interaction.getGrabInfo(this.entityId);
207
+ if (!grab || grab.hands < 2 || grab.handSeparation === null) return;
208
+
209
+ // Example: pull both hands apart to "charge" — separation is metres.
210
+ console.log("charge level", Math.min(1, grab.handSeparation / 0.8));
211
+ }
212
+ }
213
+ ```
214
+
215
+ Returns null while nothing holds the entity, so gate on it every frame.
216
+
188
217
  ## Hand poses
189
218
 
190
219
  Add an **XR Hand Pose** component and list the poses to watch for. Each is either a built-in preset or a custom set of finger curls.
@@ -102,7 +102,7 @@ Both methods accept `Vec3` (`THREE.Vector3` or `{ x, y, z }`) and return `Inters
102
102
 
103
103
  | Method | Purpose |
104
104
  | --- | --- |
105
- | `getSystem(SystemClass)` | Resolve a registered system instance. |
105
+ | `getSystem(instance)` | Resolve the REGISTERED system matching the argument's class — pass a throwaway instance (`world.getSystem(new XRHitTestSystem())`), never the class itself. |
106
106
  | `hasSystem(SystemClass)` | `true` if the system is registered. |
107
107
  | `getObjectManagementSystem()` | Shorthand for the built-in `ObjectManagementSystem`. Use this to spawn or destroy entities at runtime (`requestCreate`, `createNow`, `deleteEntity`). |
108
108
  | `getEnvironmentComponent()` | Returns the singleton [`Environment`](./scene/environment) component, or `undefined` if not yet initialized. Mutate `values` to change skybox, fog, or gravity at runtime. |
@@ -647,8 +647,29 @@ declare class XRSessionSystem implements EcsSystem {
647
647
  private readonly inputSystem;
648
648
  /** Y compensation applied when a floor-relative space was requested but not granted. */
649
649
  private appliedOriginOffset;
650
+ /** The live session, so an async floor probe can tell if it has been replaced. */
651
+ private session;
650
652
  constructor(inputSystem?: XRInputSystem);
651
653
  update(world: World): void;
654
+ /**
655
+ * Stop three's depth-occlusion pass from writing COLOR.
656
+ *
657
+ * With depth sensing granted, three draws a full-screen, head-locked mesh
658
+ * every frame whose fragment shader assigns ONLY `gl_FragDepth` — that is
659
+ * the whole occlusion mechanism — but its material never sets
660
+ * `colorWrite: false`, so the colour output is UNDEFINED per GLSL. Desktop
661
+ * drivers happen to zero it, which is why this ships unnoticed; Quest's
662
+ * mobile GPU frequently leaves the last-computed temporary in the register —
663
+ * the sampled DEPTH value — so the pass paints the depth camera's view over
664
+ * the screen as faint head-locked contour bands. On device that reads as "a
665
+ * large wireframe dome that follows my head".
666
+ *
667
+ * The mesh is created lazily inside three's render, so this is re-checked
668
+ * every frame rather than hooked once; the write is idempotent and the
669
+ * check is two property reads. Depth-only is the mesh's entire purpose —
670
+ * occlusion is unaffected.
671
+ */
672
+ private muteDepthOcclusionColor;
652
673
  private handleSessionStart;
653
674
  /**
654
675
  * Apply the rig's authored tracking origin, and compensate when the device
@@ -690,12 +711,55 @@ declare class XREnvironmentSystem implements EcsSystem {
690
711
  private readonly meshes;
691
712
  private presenting;
692
713
  private debugVisible;
693
- update(world: World): void;
714
+ /** Seconds presenting, used to decide the room has genuinely nothing in it. */
715
+ private elapsed;
716
+ /** `initiateRoomCapture` may be called only ONCE per session. */
717
+ private roomCaptureRequested;
718
+ update(world: World, deltaTime?: number): void;
719
+ /**
720
+ * Ask the runtime to scan the room when it turns out we have no room.
721
+ *
722
+ * Plane and mesh detection do NOT scan anything — they replay a scan the user
723
+ * captured earlier through the system's own room setup. Most people have
724
+ * never run it, and those who have may have run it in a DIFFERENT room, so a
725
+ * mixed-reality experience opens against either nothing at all or someone
726
+ * else's furniture, with no prompt and no error.
727
+ *
728
+ * `XRSession.initiateRoomCapture()` (Meta's extension) launches that setup
729
+ * from inside the session, and exists precisely for this case. Contract, from
730
+ * Meta's docs: callable ONCE per session, and only worth calling once we are
731
+ * confident the room really is empty — the arrays populate a beat after the
732
+ * session starts, so a check on frame one would fire for everyone.
733
+ */
734
+ private maybeCaptureRoom;
694
735
  /** How many pieces of real geometry are currently colliding. Script-facing. */
695
736
  getDetectedCount(): {
696
737
  planes: number;
697
738
  meshes: number;
698
739
  };
740
+ /**
741
+ * A census of every surface currently spawned: what it is, where it sits and
742
+ * roughly how big it is. Built for the template's post-session readout, so a
743
+ * mystery shape on the headset can be looked up by its label instead of
744
+ * guessed at — a small-radius entry at head height IS the mystery shape, and
745
+ * a clean list means whatever was seen did not come from this system.
746
+ */
747
+ getDebugSnapshot(world: World): {
748
+ kind: "plane" | "mesh";
749
+ label: string | null;
750
+ position: {
751
+ x: number;
752
+ y: number;
753
+ z: number;
754
+ };
755
+ radius: number;
756
+ /** Tracking currently lost — pose frozen at the last known place. */
757
+ emulated: boolean;
758
+ }[];
759
+ /** Create the no-scan floor once; it lives in the ordinary plane map. */
760
+ private ensureSyntheticFloor;
761
+ /** Real scene data exists (or arrived) — the stand-in must go. */
762
+ private removeSyntheticFloor;
699
763
  private findRig;
700
764
  private syncPlanes;
701
765
  private syncMeshes;
@@ -647,8 +647,29 @@ declare class XRSessionSystem implements EcsSystem {
647
647
  private readonly inputSystem;
648
648
  /** Y compensation applied when a floor-relative space was requested but not granted. */
649
649
  private appliedOriginOffset;
650
+ /** The live session, so an async floor probe can tell if it has been replaced. */
651
+ private session;
650
652
  constructor(inputSystem?: XRInputSystem);
651
653
  update(world: World): void;
654
+ /**
655
+ * Stop three's depth-occlusion pass from writing COLOR.
656
+ *
657
+ * With depth sensing granted, three draws a full-screen, head-locked mesh
658
+ * every frame whose fragment shader assigns ONLY `gl_FragDepth` — that is
659
+ * the whole occlusion mechanism — but its material never sets
660
+ * `colorWrite: false`, so the colour output is UNDEFINED per GLSL. Desktop
661
+ * drivers happen to zero it, which is why this ships unnoticed; Quest's
662
+ * mobile GPU frequently leaves the last-computed temporary in the register —
663
+ * the sampled DEPTH value — so the pass paints the depth camera's view over
664
+ * the screen as faint head-locked contour bands. On device that reads as "a
665
+ * large wireframe dome that follows my head".
666
+ *
667
+ * The mesh is created lazily inside three's render, so this is re-checked
668
+ * every frame rather than hooked once; the write is idempotent and the
669
+ * check is two property reads. Depth-only is the mesh's entire purpose —
670
+ * occlusion is unaffected.
671
+ */
672
+ private muteDepthOcclusionColor;
652
673
  private handleSessionStart;
653
674
  /**
654
675
  * Apply the rig's authored tracking origin, and compensate when the device
@@ -690,12 +711,55 @@ declare class XREnvironmentSystem implements EcsSystem {
690
711
  private readonly meshes;
691
712
  private presenting;
692
713
  private debugVisible;
693
- update(world: World): void;
714
+ /** Seconds presenting, used to decide the room has genuinely nothing in it. */
715
+ private elapsed;
716
+ /** `initiateRoomCapture` may be called only ONCE per session. */
717
+ private roomCaptureRequested;
718
+ update(world: World, deltaTime?: number): void;
719
+ /**
720
+ * Ask the runtime to scan the room when it turns out we have no room.
721
+ *
722
+ * Plane and mesh detection do NOT scan anything — they replay a scan the user
723
+ * captured earlier through the system's own room setup. Most people have
724
+ * never run it, and those who have may have run it in a DIFFERENT room, so a
725
+ * mixed-reality experience opens against either nothing at all or someone
726
+ * else's furniture, with no prompt and no error.
727
+ *
728
+ * `XRSession.initiateRoomCapture()` (Meta's extension) launches that setup
729
+ * from inside the session, and exists precisely for this case. Contract, from
730
+ * Meta's docs: callable ONCE per session, and only worth calling once we are
731
+ * confident the room really is empty — the arrays populate a beat after the
732
+ * session starts, so a check on frame one would fire for everyone.
733
+ */
734
+ private maybeCaptureRoom;
694
735
  /** How many pieces of real geometry are currently colliding. Script-facing. */
695
736
  getDetectedCount(): {
696
737
  planes: number;
697
738
  meshes: number;
698
739
  };
740
+ /**
741
+ * A census of every surface currently spawned: what it is, where it sits and
742
+ * roughly how big it is. Built for the template's post-session readout, so a
743
+ * mystery shape on the headset can be looked up by its label instead of
744
+ * guessed at — a small-radius entry at head height IS the mystery shape, and
745
+ * a clean list means whatever was seen did not come from this system.
746
+ */
747
+ getDebugSnapshot(world: World): {
748
+ kind: "plane" | "mesh";
749
+ label: string | null;
750
+ position: {
751
+ x: number;
752
+ y: number;
753
+ z: number;
754
+ };
755
+ radius: number;
756
+ /** Tracking currently lost — pose frozen at the last known place. */
757
+ emulated: boolean;
758
+ }[];
759
+ /** Create the no-scan floor once; it lives in the ordinary plane map. */
760
+ private ensureSyntheticFloor;
761
+ /** Real scene data exists (or arrived) — the stand-in must go. */
762
+ private removeSyntheticFloor;
699
763
  private findRig;
700
764
  private syncPlanes;
701
765
  private syncMeshes;