@relu-interactives/spatial-ecs 0.4.4 → 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.
- package/dist/{chunk-OY2OONRR.js → chunk-QIIWFP3V.js} +194 -21
- package/dist/chunk-QIIWFP3V.js.map +1 -0
- package/dist/docs/guide/mixed-reality.md +137 -3
- package/dist/docs/guide/vr-and-hand-poses.md +29 -0
- package/dist/docs/guide/world-api.md +1 -1
- package/dist/{index-B2oHfqkj.d.ts → index-BphKHaV-.d.ts} +42 -0
- package/dist/{index-7YCk4cmQ.d.cts → index-CH7qMsQT.d.cts} +42 -0
- package/dist/index.cjs +192 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/systems/index.cjs +192 -19
- package/dist/systems/index.cjs.map +1 -1
- package/dist/systems/index.d.cts +1 -1
- package/dist/systems/index.d.ts +1 -1
- package/dist/systems/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-OY2OONRR.js.map +0 -1
|
@@ -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.
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
|
@@ -64,6 +89,64 @@ If a user reports "it never asked to scan my room" and the geometry does not
|
|
|
64
89
|
match, they are almost certainly running against a stale scan from another
|
|
65
90
|
space; re-running Space Setup for the current room fixes it.
|
|
66
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
|
+
|
|
67
150
|
## Finding the real room from a script
|
|
68
151
|
|
|
69
152
|
Detected surfaces carry `XREnvironmentComponent`, so they are queryable like any
|
|
@@ -116,6 +199,36 @@ export default class RoomAware extends Behaviour {
|
|
|
116
199
|
}
|
|
117
200
|
```
|
|
118
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
|
+
|
|
119
232
|
## Anchors
|
|
120
233
|
|
|
121
234
|
A headset keeps revising where it thinks the room is. Content at fixed scene
|
|
@@ -129,6 +242,27 @@ on, and it stays put relative to the room.
|
|
|
129
242
|
Because the anchor owns the transform, moving the entity by script or physics has
|
|
130
243
|
no lasting effect — disable the component first, move it, then re-enable.
|
|
131
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
|
+
|
|
132
266
|
Anchors last for the session only. Persisting one across launches needs
|
|
133
267
|
`requestPersistentHandle`, which is a draft extension and not supported here.
|
|
134
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(
|
|
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. |
|
|
@@ -651,6 +651,25 @@ declare class XRSessionSystem implements EcsSystem {
|
|
|
651
651
|
private session;
|
|
652
652
|
constructor(inputSystem?: XRInputSystem);
|
|
653
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;
|
|
654
673
|
private handleSessionStart;
|
|
655
674
|
/**
|
|
656
675
|
* Apply the rig's authored tracking origin, and compensate when the device
|
|
@@ -718,6 +737,29 @@ declare class XREnvironmentSystem implements EcsSystem {
|
|
|
718
737
|
planes: number;
|
|
719
738
|
meshes: number;
|
|
720
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;
|
|
721
763
|
private findRig;
|
|
722
764
|
private syncPlanes;
|
|
723
765
|
private syncMeshes;
|
|
@@ -651,6 +651,25 @@ declare class XRSessionSystem implements EcsSystem {
|
|
|
651
651
|
private session;
|
|
652
652
|
constructor(inputSystem?: XRInputSystem);
|
|
653
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;
|
|
654
673
|
private handleSessionStart;
|
|
655
674
|
/**
|
|
656
675
|
* Apply the rig's authored tracking origin, and compensate when the device
|
|
@@ -718,6 +737,29 @@ declare class XREnvironmentSystem implements EcsSystem {
|
|
|
718
737
|
planes: number;
|
|
719
738
|
meshes: number;
|
|
720
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;
|
|
721
763
|
private findRig;
|
|
722
764
|
private syncPlanes;
|
|
723
765
|
private syncMeshes;
|
package/dist/index.cjs
CHANGED
|
@@ -12059,6 +12059,7 @@ var _XRLocomotionSystem = class _XRLocomotionSystem {
|
|
|
12059
12059
|
const rig = world.getComponent(rigEntityId, XRRigComponent);
|
|
12060
12060
|
const rigTransform = world.getComponent(rigEntityId, TransformComponent);
|
|
12061
12061
|
if (!rig || !rigTransform) return;
|
|
12062
|
+
if (world.isXrPassthrough()) return;
|
|
12062
12063
|
const inputs = this.inputSystem.getInputs();
|
|
12063
12064
|
if (inputs.length === 0) return;
|
|
12064
12065
|
if (rig.snapTurn) {
|
|
@@ -12321,6 +12322,32 @@ var _XRSessionSystem = class _XRSessionSystem {
|
|
|
12321
12322
|
}
|
|
12322
12323
|
if (presenting) {
|
|
12323
12324
|
this.dispatchInputEdges(world);
|
|
12325
|
+
this.muteDepthOcclusionColor(world);
|
|
12326
|
+
}
|
|
12327
|
+
}
|
|
12328
|
+
/**
|
|
12329
|
+
* Stop three's depth-occlusion pass from writing COLOR.
|
|
12330
|
+
*
|
|
12331
|
+
* With depth sensing granted, three draws a full-screen, head-locked mesh
|
|
12332
|
+
* every frame whose fragment shader assigns ONLY `gl_FragDepth` — that is
|
|
12333
|
+
* the whole occlusion mechanism — but its material never sets
|
|
12334
|
+
* `colorWrite: false`, so the colour output is UNDEFINED per GLSL. Desktop
|
|
12335
|
+
* drivers happen to zero it, which is why this ships unnoticed; Quest's
|
|
12336
|
+
* mobile GPU frequently leaves the last-computed temporary in the register —
|
|
12337
|
+
* the sampled DEPTH value — so the pass paints the depth camera's view over
|
|
12338
|
+
* the screen as faint head-locked contour bands. On device that reads as "a
|
|
12339
|
+
* large wireframe dome that follows my head".
|
|
12340
|
+
*
|
|
12341
|
+
* The mesh is created lazily inside three's render, so this is re-checked
|
|
12342
|
+
* every frame rather than hooked once; the write is idempotent and the
|
|
12343
|
+
* check is two property reads. Depth-only is the mesh's entire purpose —
|
|
12344
|
+
* occlusion is unaffected.
|
|
12345
|
+
*/
|
|
12346
|
+
muteDepthOcclusionColor(world) {
|
|
12347
|
+
const xr = world.getRenderer().xr;
|
|
12348
|
+
const material = xr.getDepthSensingMesh?.()?.material;
|
|
12349
|
+
if (material && material.colorWrite !== false) {
|
|
12350
|
+
material.colorWrite = false;
|
|
12324
12351
|
}
|
|
12325
12352
|
}
|
|
12326
12353
|
handleSessionStart(world) {
|
|
@@ -12474,8 +12501,12 @@ function planeColliderFromPolygon(polygon) {
|
|
|
12474
12501
|
__name(planeColliderFromPolygon, "planeColliderFromPolygon");
|
|
12475
12502
|
function writeXrMeshGeometry(vertices, indices, existing) {
|
|
12476
12503
|
const geometry = existing ?? new THREE51__namespace.BufferGeometry();
|
|
12477
|
-
geometry.setAttribute(
|
|
12478
|
-
|
|
12504
|
+
geometry.setAttribute(
|
|
12505
|
+
"position",
|
|
12506
|
+
new THREE51__namespace.BufferAttribute(vertices.slice(), 3)
|
|
12507
|
+
);
|
|
12508
|
+
geometry.setIndex(new THREE51__namespace.BufferAttribute(indices.slice(), 1));
|
|
12509
|
+
geometry.deleteAttribute("normal");
|
|
12479
12510
|
geometry.computeBoundingSphere();
|
|
12480
12511
|
geometry.computeVertexNormals();
|
|
12481
12512
|
return geometry;
|
|
@@ -12501,7 +12532,42 @@ __name(decomposeXrPose, "decomposeXrPose");
|
|
|
12501
12532
|
|
|
12502
12533
|
// core/systems/XREnvironmentSystem.ts
|
|
12503
12534
|
var DEBUG_COLOR = 5088255;
|
|
12535
|
+
function debugColorFor(kind, semanticLabel) {
|
|
12536
|
+
switch (semanticLabel) {
|
|
12537
|
+
case "floor":
|
|
12538
|
+
return 5100666;
|
|
12539
|
+
// green — the surface everything should rest on
|
|
12540
|
+
case "ceiling":
|
|
12541
|
+
return 11693055;
|
|
12542
|
+
// purple
|
|
12543
|
+
case "wall":
|
|
12544
|
+
case "wall face":
|
|
12545
|
+
return DEBUG_COLOR;
|
|
12546
|
+
// blue, the family's base colour
|
|
12547
|
+
case "global mesh":
|
|
12548
|
+
return 9079442;
|
|
12549
|
+
// dim gray — raw scan, not an object
|
|
12550
|
+
default:
|
|
12551
|
+
return semanticLabel ? 16747594 : DEBUG_COLOR;
|
|
12552
|
+
}
|
|
12553
|
+
}
|
|
12554
|
+
__name(debugColorFor, "debugColorFor");
|
|
12555
|
+
var GLOBAL_MESH_DEBUG_OPACITY = 0.35;
|
|
12556
|
+
var DEBUG_RENDER_ORDER = 900;
|
|
12504
12557
|
var ROOM_CAPTURE_DELAY_SECONDS = 3;
|
|
12558
|
+
var SYNTHETIC_FLOOR_EXTENT = 50;
|
|
12559
|
+
var SYNTHETIC_FLOOR_PLANE = {
|
|
12560
|
+
planeSpace: {},
|
|
12561
|
+
polygon: [
|
|
12562
|
+
{ x: -SYNTHETIC_FLOOR_EXTENT / 2, y: 0, z: -SYNTHETIC_FLOOR_EXTENT / 2 },
|
|
12563
|
+
{ x: SYNTHETIC_FLOOR_EXTENT / 2, y: 0, z: -SYNTHETIC_FLOOR_EXTENT / 2 },
|
|
12564
|
+
{ x: SYNTHETIC_FLOOR_EXTENT / 2, y: 0, z: SYNTHETIC_FLOOR_EXTENT / 2 },
|
|
12565
|
+
{ x: -SYNTHETIC_FLOOR_EXTENT / 2, y: 0, z: SYNTHETIC_FLOOR_EXTENT / 2 }
|
|
12566
|
+
],
|
|
12567
|
+
orientation: "horizontal",
|
|
12568
|
+
semanticLabel: "floor",
|
|
12569
|
+
lastChangedTime: 0
|
|
12570
|
+
};
|
|
12505
12571
|
var _XREnvironmentSystem = class _XREnvironmentSystem {
|
|
12506
12572
|
constructor() {
|
|
12507
12573
|
/** Keyed by the XRPlane/XRMesh object itself — the runtime's own identity. */
|
|
@@ -12542,6 +12608,11 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
|
|
|
12542
12608
|
const frame = xr.getFrame?.();
|
|
12543
12609
|
const referenceSpace = xr.getReferenceSpace();
|
|
12544
12610
|
if (!frame || !referenceSpace) return;
|
|
12611
|
+
if (frame.detectedPlanes === void 0 && frame.detectedMeshes === void 0) {
|
|
12612
|
+
this.ensureSyntheticFloor(world);
|
|
12613
|
+
return;
|
|
12614
|
+
}
|
|
12615
|
+
this.removeSyntheticFloor(world);
|
|
12545
12616
|
const meshesAvailable = Boolean(frame.detectedMeshes);
|
|
12546
12617
|
const useMesh = (mode === "mesh" || mode === "auto") && meshesAvailable;
|
|
12547
12618
|
const usePlanes = mode === "planes" || mode === "auto" && !useMesh;
|
|
@@ -12588,6 +12659,51 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
|
|
|
12588
12659
|
getDetectedCount() {
|
|
12589
12660
|
return { planes: this.planes.size, meshes: this.meshes.size };
|
|
12590
12661
|
}
|
|
12662
|
+
/**
|
|
12663
|
+
* A census of every surface currently spawned: what it is, where it sits and
|
|
12664
|
+
* roughly how big it is. Built for the template's post-session readout, so a
|
|
12665
|
+
* mystery shape on the headset can be looked up by its label instead of
|
|
12666
|
+
* guessed at — a small-radius entry at head height IS the mystery shape, and
|
|
12667
|
+
* a clean list means whatever was seen did not come from this system.
|
|
12668
|
+
*/
|
|
12669
|
+
getDebugSnapshot(world) {
|
|
12670
|
+
const snapshot = [];
|
|
12671
|
+
for (const [plane, entry] of this.planes) {
|
|
12672
|
+
const transform = world.getComponent(entry.entityId, TransformComponent);
|
|
12673
|
+
const collider = planeColliderFromPolygon(plane.polygon);
|
|
12674
|
+
snapshot.push({
|
|
12675
|
+
kind: "plane",
|
|
12676
|
+
label: plane.semanticLabel ?? null,
|
|
12677
|
+
position: transform ? { ...transform.position } : { x: 0, y: 0, z: 0 },
|
|
12678
|
+
radius: collider ? Math.hypot(collider.size.x, collider.size.z) / 2 : 0,
|
|
12679
|
+
emulated: entry.emulated
|
|
12680
|
+
});
|
|
12681
|
+
}
|
|
12682
|
+
for (const [mesh, entry] of this.meshes) {
|
|
12683
|
+
const transform = world.getComponent(entry.entityId, TransformComponent);
|
|
12684
|
+
snapshot.push({
|
|
12685
|
+
kind: "mesh",
|
|
12686
|
+
label: mesh.semanticLabel ?? null,
|
|
12687
|
+
position: transform ? { ...transform.position } : { x: 0, y: 0, z: 0 },
|
|
12688
|
+
radius: entry.geometry?.boundingSphere?.radius ?? 0,
|
|
12689
|
+
emulated: entry.emulated
|
|
12690
|
+
});
|
|
12691
|
+
}
|
|
12692
|
+
return snapshot;
|
|
12693
|
+
}
|
|
12694
|
+
/** Create the no-scan floor once; it lives in the ordinary plane map. */
|
|
12695
|
+
ensureSyntheticFloor(world) {
|
|
12696
|
+
if (this.planes.has(SYNTHETIC_FLOOR_PLANE)) return;
|
|
12697
|
+
const created = this.createPlaneEntity(world, SYNTHETIC_FLOOR_PLANE);
|
|
12698
|
+
if (created) this.planes.set(SYNTHETIC_FLOOR_PLANE, created);
|
|
12699
|
+
}
|
|
12700
|
+
/** Real scene data exists (or arrived) — the stand-in must go. */
|
|
12701
|
+
removeSyntheticFloor(world) {
|
|
12702
|
+
const entry = this.planes.get(SYNTHETIC_FLOOR_PLANE);
|
|
12703
|
+
if (!entry) return;
|
|
12704
|
+
this.destroyEntry(world, entry);
|
|
12705
|
+
this.planes.delete(SYNTHETIC_FLOOR_PLANE);
|
|
12706
|
+
}
|
|
12591
12707
|
findRig(world) {
|
|
12592
12708
|
const ids = world.query(XRRigComponent);
|
|
12593
12709
|
if (ids.length === 0) return null;
|
|
@@ -12605,9 +12721,11 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
|
|
|
12605
12721
|
for (const plane of detected) {
|
|
12606
12722
|
const pose = frame.getPose(plane.planeSpace, referenceSpace);
|
|
12607
12723
|
if (!pose) continue;
|
|
12724
|
+
const emulated = pose.emulatedPosition === true;
|
|
12608
12725
|
let entry = this.planes.get(plane);
|
|
12609
12726
|
const changed = plane.lastChangedTime ?? 0;
|
|
12610
12727
|
if (!entry) {
|
|
12728
|
+
if (emulated) continue;
|
|
12611
12729
|
const created = this.createPlaneEntity(world, plane);
|
|
12612
12730
|
if (!created) continue;
|
|
12613
12731
|
entry = created;
|
|
@@ -12620,11 +12738,17 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
|
|
|
12620
12738
|
if (collider) {
|
|
12621
12739
|
entry.geometry?.dispose();
|
|
12622
12740
|
entry.geometry = null;
|
|
12623
|
-
this.attachPlaneDebug(
|
|
12741
|
+
this.attachPlaneDebug(
|
|
12742
|
+
world,
|
|
12743
|
+
entry,
|
|
12744
|
+
collider,
|
|
12745
|
+
plane.semanticLabel ?? null
|
|
12746
|
+
);
|
|
12624
12747
|
}
|
|
12625
12748
|
}
|
|
12626
12749
|
}
|
|
12627
|
-
|
|
12750
|
+
entry.emulated = emulated;
|
|
12751
|
+
if (!emulated) this.applyPose(world, entry.entityId, pose);
|
|
12628
12752
|
}
|
|
12629
12753
|
}
|
|
12630
12754
|
syncMeshes(world, frame, referenceSpace) {
|
|
@@ -12639,9 +12763,11 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
|
|
|
12639
12763
|
for (const mesh of detected) {
|
|
12640
12764
|
const pose = frame.getPose(mesh.meshSpace, referenceSpace);
|
|
12641
12765
|
if (!pose) continue;
|
|
12766
|
+
const emulated = pose.emulatedPosition === true;
|
|
12642
12767
|
let entry = this.meshes.get(mesh);
|
|
12643
12768
|
const changed = mesh.lastChangedTime ?? 0;
|
|
12644
12769
|
if (!entry) {
|
|
12770
|
+
if (emulated) continue;
|
|
12645
12771
|
const created = this.createMeshEntity(world, mesh);
|
|
12646
12772
|
if (!created) continue;
|
|
12647
12773
|
entry = created;
|
|
@@ -12650,7 +12776,8 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
|
|
|
12650
12776
|
entry.lastChangedTime = changed;
|
|
12651
12777
|
writeXrMeshGeometry(mesh.vertices, mesh.indices, entry.geometry);
|
|
12652
12778
|
}
|
|
12653
|
-
|
|
12779
|
+
entry.emulated = emulated;
|
|
12780
|
+
if (!emulated) this.applyPose(world, entry.entityId, pose);
|
|
12654
12781
|
}
|
|
12655
12782
|
}
|
|
12656
12783
|
createPlaneEntity(world, plane) {
|
|
@@ -12670,9 +12797,12 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
|
|
|
12670
12797
|
entityId,
|
|
12671
12798
|
lastChangedTime: plane.lastChangedTime ?? 0,
|
|
12672
12799
|
geometry: null,
|
|
12673
|
-
material: null
|
|
12800
|
+
material: null,
|
|
12801
|
+
emulated: false
|
|
12674
12802
|
};
|
|
12675
|
-
if (this.debugVisible)
|
|
12803
|
+
if (this.debugVisible) {
|
|
12804
|
+
this.attachPlaneDebug(world, entry, collider, plane.semanticLabel ?? null);
|
|
12805
|
+
}
|
|
12676
12806
|
return entry;
|
|
12677
12807
|
}
|
|
12678
12808
|
applyPlaneCollider(world, entityId, plane) {
|
|
@@ -12697,15 +12827,36 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
|
|
|
12697
12827
|
createMeshEntity(world, mesh) {
|
|
12698
12828
|
if (!mesh.vertices?.length || !mesh.indices?.length) return null;
|
|
12699
12829
|
const geometry = writeXrMeshGeometry(mesh.vertices, mesh.indices);
|
|
12830
|
+
const label = mesh.semanticLabel ?? null;
|
|
12831
|
+
const isGlobalMesh = label === "global mesh";
|
|
12700
12832
|
const material = new THREE51__namespace.MeshBasicMaterial({
|
|
12701
|
-
color:
|
|
12702
|
-
wireframe: true
|
|
12833
|
+
color: debugColorFor("mesh", label),
|
|
12834
|
+
wireframe: true,
|
|
12835
|
+
// The global scan is a coarse triangle soup of the whole room; at full
|
|
12836
|
+
// strength it reads as phantom objects rather than as context.
|
|
12837
|
+
transparent: isGlobalMesh,
|
|
12838
|
+
opacity: isGlobalMesh ? GLOBAL_MESH_DEBUG_OPACITY : 1,
|
|
12839
|
+
// Depth-TESTED, deliberately. The overlay was X-ray for one release —
|
|
12840
|
+
// introduced on the theory that depth occlusion was carving it into a
|
|
12841
|
+
// head-locked dome — but that theory was falsified (the dome was the
|
|
12842
|
+
// depth mesh's unset colour output, plus emulated poses; both fixed).
|
|
12843
|
+
// X-ray had its own on-device failure: the global scan is DENSE, and a
|
|
12844
|
+
// dense wireframe at 0.35 opacity fuses into a translucent sheet — with
|
|
12845
|
+
// no depth test the walls' sheets drew through everything, stacked in
|
|
12846
|
+
// front of the user's eyes ("planes covering my face"). With the depth
|
|
12847
|
+
// buffer now holding CORRECT real-world depth, normal testing gives the
|
|
12848
|
+
// right picture: the near wall hides the far one. depthWrite stays off
|
|
12849
|
+
// so the overlay never occludes actual content.
|
|
12850
|
+
depthTest: true,
|
|
12851
|
+
depthWrite: false
|
|
12703
12852
|
});
|
|
12704
12853
|
const root = new THREE51__namespace.Group();
|
|
12705
12854
|
const child = new THREE51__namespace.Mesh(geometry, material);
|
|
12706
12855
|
child.visible = this.debugVisible;
|
|
12856
|
+
child.renderOrder = DEBUG_RENDER_ORDER;
|
|
12707
12857
|
root.add(child);
|
|
12708
12858
|
const entityId = world.createEntity();
|
|
12859
|
+
root.userData.entityId = entityId;
|
|
12709
12860
|
world.addComponents(entityId, [
|
|
12710
12861
|
new TransformComponent(),
|
|
12711
12862
|
new Object3DRef(root),
|
|
@@ -12719,29 +12870,37 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
|
|
|
12719
12870
|
entityId,
|
|
12720
12871
|
lastChangedTime: mesh.lastChangedTime ?? 0,
|
|
12721
12872
|
geometry,
|
|
12722
|
-
material
|
|
12873
|
+
material,
|
|
12874
|
+
emulated: false
|
|
12723
12875
|
};
|
|
12724
12876
|
}
|
|
12725
12877
|
/** A thin box so a detected plane can be SEEN, not just collided with. */
|
|
12726
|
-
attachPlaneDebug(world, entry, collider) {
|
|
12727
|
-
const
|
|
12878
|
+
attachPlaneDebug(world, entry, collider, semanticLabel) {
|
|
12879
|
+
const box = new THREE51__namespace.BoxGeometry(
|
|
12728
12880
|
collider.size.x,
|
|
12729
12881
|
collider.size.y,
|
|
12730
12882
|
collider.size.z
|
|
12731
12883
|
);
|
|
12732
|
-
const
|
|
12733
|
-
|
|
12734
|
-
|
|
12884
|
+
const geometry = new THREE51__namespace.EdgesGeometry(box);
|
|
12885
|
+
box.dispose();
|
|
12886
|
+
const material = new THREE51__namespace.LineBasicMaterial({
|
|
12887
|
+
color: debugColorFor("plane", semanticLabel),
|
|
12888
|
+
// Same depth policy as the mesh overlay — see createMeshEntity.
|
|
12889
|
+
depthTest: true,
|
|
12890
|
+
depthWrite: false
|
|
12735
12891
|
});
|
|
12736
12892
|
const root = new THREE51__namespace.Group();
|
|
12737
|
-
const child = new THREE51__namespace.
|
|
12893
|
+
const child = new THREE51__namespace.LineSegments(geometry, material);
|
|
12894
|
+
child.renderOrder = DEBUG_RENDER_ORDER;
|
|
12738
12895
|
child.position.set(collider.offset.x, collider.offset.y, collider.offset.z);
|
|
12739
12896
|
child.visible = this.debugVisible;
|
|
12740
12897
|
root.add(child);
|
|
12741
12898
|
entry.geometry = geometry;
|
|
12742
12899
|
entry.material = material;
|
|
12900
|
+
root.userData.entityId = entry.entityId;
|
|
12743
12901
|
const existing = world.getComponent(entry.entityId, Object3DRef);
|
|
12744
12902
|
if (existing) {
|
|
12903
|
+
existing.object.removeFromParent();
|
|
12745
12904
|
existing.object = root;
|
|
12746
12905
|
} else {
|
|
12747
12906
|
world.addComponent(entry.entityId, new Object3DRef(root));
|
|
@@ -12757,19 +12916,31 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
|
|
|
12757
12916
|
for (const [plane, entry] of this.planes) {
|
|
12758
12917
|
if (entry.geometry) continue;
|
|
12759
12918
|
const collider = planeColliderFromPolygon(plane.polygon);
|
|
12760
|
-
if (collider)
|
|
12919
|
+
if (collider) {
|
|
12920
|
+
this.attachPlaneDebug(
|
|
12921
|
+
world,
|
|
12922
|
+
entry,
|
|
12923
|
+
collider,
|
|
12924
|
+
plane.semanticLabel ?? null
|
|
12925
|
+
);
|
|
12926
|
+
}
|
|
12761
12927
|
}
|
|
12762
12928
|
}
|
|
12763
12929
|
for (const map of [this.planes, this.meshes]) {
|
|
12764
12930
|
for (const entry of map.values()) {
|
|
12765
12931
|
const ref = world.getComponent(entry.entityId, Object3DRef);
|
|
12766
12932
|
ref?.object.traverse((child) => {
|
|
12767
|
-
|
|
12933
|
+
const drawable = child;
|
|
12934
|
+
if (drawable.isMesh || drawable.isLine) {
|
|
12935
|
+
child.visible = this.debugVisible;
|
|
12936
|
+
}
|
|
12768
12937
|
});
|
|
12769
12938
|
}
|
|
12770
12939
|
}
|
|
12771
12940
|
}
|
|
12772
12941
|
destroyEntry(world, entry) {
|
|
12942
|
+
const ref = world.getComponent(entry.entityId, Object3DRef);
|
|
12943
|
+
ref?.object.removeFromParent();
|
|
12773
12944
|
entry.geometry?.dispose();
|
|
12774
12945
|
entry.material?.dispose();
|
|
12775
12946
|
world.destroyEntity(entry.entityId);
|
|
@@ -12946,7 +13117,9 @@ var _XRAnchorSystem = class _XRAnchorSystem {
|
|
|
12946
13117
|
continue;
|
|
12947
13118
|
}
|
|
12948
13119
|
const pose = frame.getPose(anchor.anchorSpace, referenceSpace);
|
|
12949
|
-
if (pose
|
|
13120
|
+
if (pose && pose.emulatedPosition !== true) {
|
|
13121
|
+
decomposeXrPose(pose.transform.matrix, transform);
|
|
13122
|
+
}
|
|
12950
13123
|
}
|
|
12951
13124
|
}
|
|
12952
13125
|
/** Whether this entity's pose is currently being driven by an anchor. */
|