Needle Engine

Changes between version 3.45.2-beta.5 and 3.46.0-beta
Files changed (9) hide show
  1. src/engine/engine_context.ts +1 -3
  2. src/engine/engine_physics.ts +56 -2
  3. src/engine-components/GroundProjection.ts +74 -12
  4. src/engine/webcomponents/needle menu/needle-menu.ts +21 -0
  5. src/engine/xr/NeedleXRController.ts +98 -37
  6. src/engine/xr/NeedleXRSession.ts +47 -7
  7. src/engine-components/webxr/WebARSessionRoot.ts +6 -0
  8. src/engine-components/webxr/controllers/XRControllerModel.ts +18 -11
  9. src/engine-components/webxr/controllers/XRControllerMovement.ts +2 -2
src/engine/engine_context.ts CHANGED
@@ -1334,9 +1334,6 @@
1334
1334
  this.domElement.dispatchEvent(new CustomEvent("ready"));
1335
1335
  ContextRegistry.dispatchCallback(ContextEvent.ContextFirstFrameRendered, this);
1336
1336
  }
1337
-
1338
-
1339
- this.renderer.info.reset();
1340
1337
  }
1341
1338
 
1342
1339
  renderNow(camera?: Camera) {
@@ -1345,6 +1342,7 @@
1345
1342
  if (!camera) return false;
1346
1343
  }
1347
1344
  this._isRendering = true;
1345
+ this.renderer.info.reset();
1348
1346
  this.renderRequiredTextures();
1349
1347
 
1350
1348
  if (this.renderer.toneMapping !== NoToneMapping)
src/engine/engine_physics.ts CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { Gizmos } from './engine_gizmos.js';
5
5
  import { Context } from './engine_setup.js';
6
- import { getWorldPosition } from "./engine_three_utils.js"
6
+ import { getTempVector, getWorldPosition } from "./engine_three_utils.js"
7
7
  import type { Vec2, Vec3, } from './engine_types.js';
8
8
  import type { IPhysicsEngine } from './engine_types.js';
9
9
  import { getParam } from "./engine_utils.js"
@@ -31,7 +31,16 @@
31
31
  results?: Array<Intersection>;
32
32
  /** Objects to raycast against. If no target array is provided the whole scene will be raycasted */
33
33
  targets?: Array<Object3D>;
34
+ /**
35
+ * If true, the raycaster will traverse the scene recursively.
36
+ * @default true
37
+ */
34
38
  recursive?: boolean;
39
+ /**
40
+ * If true, the raycaster will use a more precise method to test for intersections. This is slower but more accurate.
41
+ * @default true
42
+ */
43
+ precise?: boolean;
35
44
  /** Set the raycaster near distance:
36
45
  * The near factor of the raycaster. This value indicates which objects can be discarded based on the distance. This value shouldn't be negative and should be smaller than the far property.
37
46
  * @link https://threejs.org/docs/#api/en/core/Raycaster.near
@@ -312,7 +321,13 @@
312
321
  const raycastMesh = getRaycastMesh(obj);
313
322
  if (raycastMesh) mesh.geometry = raycastMesh;
314
323
  const lastResultsCount = results.length;
315
- raycaster.intersectObject(obj, false, results);
324
+ if (options.precise === false && customRaycast(mesh, raycaster, results)) {
325
+ // did handle raycast
326
+ }
327
+ else {
328
+ raycaster.intersectObject(obj, false, results);
329
+ }
330
+
316
331
  if (debugPhysics && results.length != lastResultsCount)
317
332
  Gizmos.DrawWireMesh({ mesh: obj as Mesh, depthTest: false, duration: .2, color: raycastMesh ? 0x88dd55 : 0x770000 })
318
333
  mesh.geometry = geometry;
@@ -326,3 +341,42 @@
326
341
  return results;
327
342
  }
328
343
  }
344
+
345
+ const tempSphere = new Sphere();
346
+ /**
347
+ * @returns false if custom raycasting can not run, otherwise true
348
+ */
349
+ function customRaycast(mesh: Mesh, raycaster: Raycaster, results: Intersection[]): boolean {
350
+ const originalComputeIntersectionsFn = mesh["_computeIntersections"];
351
+ if (!originalComputeIntersectionsFn) {
352
+ return false;
353
+ }
354
+
355
+ // compute custom intersection, check if a custom method already exists
356
+ let computeCustomIntersectionFn = mesh["_computeIntersections:Needle"];
357
+ if (!computeCustomIntersectionFn) {
358
+ // create and bind a custom method once to the mesh object
359
+ // TODO: maybe we want to add this to the prototype instead
360
+ computeCustomIntersectionFn = mesh["_computeIntersections:Needle"] = function (raycaster: Raycaster, intersects: Intersection[], _rayLocalSpace: Ray) {
361
+ const self = this as Mesh;
362
+ const boundingSphere = self.geometry.boundingSphere;
363
+ if (boundingSphere) {
364
+ tempSphere.copy(boundingSphere);
365
+ tempSphere.applyMatrix4(self.matrixWorld);
366
+ const point = raycaster.ray.intersectSphere(tempSphere, getTempVector());
367
+ if (point) {
368
+ // const dir = getTempVector(tempSphere.center).sub(raycaster.ray.origin);
369
+ // const pointOnSphere = getTempVector(dir).normalize().multiplyScalar(tempSphere.radius);
370
+ // // const pointOnBoundingSphere = raycaster.ray.closestPointToPoint(boundingSphere.center, getTempVector());
371
+ // const distance = dir.length();//pointOnBoundingSphere.distanceTo(raycaster.ray.origin);
372
+ const distance = point.distanceTo(raycaster.ray.origin);
373
+ intersects.push({ object: self, distance: distance, point: point });
374
+ }
375
+ }
376
+ }
377
+ }
378
+ mesh["_computeIntersections"] = computeCustomIntersectionFn;
379
+ raycaster.intersectObject(mesh, false, results);
380
+ mesh["_computeIntersections"] = originalComputeIntersectionsFn;
381
+ return true;
382
+ }
src/engine-components/GroundProjection.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  import { getBoundingBox, getWorldPosition, Graphics, setWorldPosition } from "../engine/engine_three_utils.js";
6
6
  import { getParam, Watch as Watch } from "../engine/engine_utils.js";
7
7
  import { Behaviour } from "./Component.js";
8
+ import { buildMatrix } from "./export/usdz/ThreeUSDZExporter.js";
9
+ import { LUTOperation } from "postprocessing";
8
10
 
9
11
  const debug = getParam("debuggroundprojection");
10
12
 
@@ -13,17 +15,23 @@
13
15
  */
14
16
  export class GroundProjectedEnv extends Behaviour {
15
17
 
18
+ /**
19
+ * If true the projection will be created on awake and onEnable
20
+ * @default false
21
+ */
16
22
  @serializable()
17
23
  applyOnAwake: boolean = false;
18
24
 
19
25
  /**
20
26
  * When enabled the position of the projected environment will be adjusted to be centered in the scene (and ground level).
27
+ * @default true
21
28
  */
22
29
  @serializable()
23
30
  autoFit: boolean = true;
24
31
 
25
32
  /**
26
33
  * Radius of the projection sphere. Set it large enough so the camera stays inside (make sure the far plane is also large enough)
34
+ * @default 50
27
35
  */
28
36
  @serializable()
29
37
  set radius(val: number) {
@@ -31,10 +39,11 @@
31
39
  this.updateProjection();
32
40
  }
33
41
  get radius(): number { return this._radius; }
34
- private _radius: number = 100;
42
+ private _radius: number = 50;
35
43
 
36
44
  /**
37
45
  * How far the camera that took the photo was above the ground. A larger value will magnify the downward part of the image.
46
+ * @sefault 3
38
47
  */
39
48
  @serializable()
40
49
  set height(val: number) {
@@ -44,6 +53,20 @@
44
53
  get height(): number { return this._height; }
45
54
  private _height: number = 3;
46
55
 
56
+ /**
57
+ * Blending factor for the AR projection being blended with the scene background.
58
+ * 0 = not visible in AR - 1 = blended with real world background.
59
+ * Values between 0 and 1 control the smoothness of the blend while lower values result in smoother blending.
60
+ * @default 0
61
+ */
62
+ @serializable()
63
+ set arBlending(val: number) {
64
+ this._arblending = val;
65
+ this._needsTextureUpdate = true;
66
+ }
67
+ get arBlending(): number { return this._arblending; }
68
+ private _arblending = 0;
69
+
47
70
  private _lastEnvironment?: Texture;
48
71
  private _lastRadius?: number;
49
72
  private _lastHeight?: number;
@@ -77,6 +100,7 @@
77
100
  }
78
101
  /** @internal */
79
102
  onEnterXR(): void {
103
+ this._needsTextureUpdate = true;
80
104
  this.updateProjection();
81
105
  }
82
106
  /** @internal */
@@ -89,7 +113,11 @@
89
113
  this._projection.rotation.copy(this.scene.backgroundRotation);
90
114
  }
91
115
 
92
- if (this.context.scene.backgroundBlurriness !== undefined && this._lastBlurriness != this.context.scene.backgroundBlurriness && this.context.scene.backgroundBlurriness > 0.001) {
116
+ const blurrinessChanged = this.context.scene.backgroundBlurriness !== undefined && this._lastBlurriness != this.context.scene.backgroundBlurriness && this.context.scene.backgroundBlurriness > 0.001;
117
+ if (blurrinessChanged) {
118
+ this.updateProjection();
119
+ }
120
+ else if (this._needsTextureUpdate) {
93
121
  this.updateBlurriness();
94
122
  }
95
123
  }
@@ -99,24 +127,30 @@
99
127
  this._watcher?.apply();
100
128
  }
101
129
 
130
+
131
+ private _needsTextureUpdate = false;
132
+
102
133
  /**
103
- * Updates the ground projection. This is called automatically when the environment changes.
134
+ * Updates the ground projection. This is called automatically when the environment or settings change.
104
135
  */
105
136
  updateProjection() {
106
- if (!this.context.scene.environment || (this.context.xr?.isPassThrough || this.context.xr?.isAR)) {
137
+ if (!this.context.scene.environment) {
107
138
  this._projection?.removeFromParent();
108
139
  return;
109
140
  }
141
+ if (this.context.xr?.isPassThrough || this.context.xr?.isAR) {
142
+ if (this.arBlending === 0) {
143
+ this._projection?.removeFromParent();
144
+ return;
145
+ }
146
+ }
110
147
  const offset = .01;
111
148
  if (!this._projection || this.context.scene.environment !== this._lastEnvironment || this._height !== this._lastHeight || this._radius !== this._lastRadius) {
112
149
  if (debug)
113
150
  console.log("Create/Update Ground Projection", this.context.scene.environment.name);
114
151
  this._projection?.removeFromParent();
115
- this._projection = new GroundProjection(this.context.scene.environment, this._height, this.radius);
152
+ this._projection = new GroundProjection(this.context.scene.environment, this._height, this.radius, 64);
116
153
  this._projection.position.y = this._height - offset;
117
- if (this.context.scene.backgroundBlurriness > 0.001) {
118
- this.updateBlurriness();
119
- }
120
154
  }
121
155
 
122
156
  this._lastEnvironment = this.context.scene.environment;
@@ -149,6 +183,12 @@
149
183
  if (this._projection.isObject3D === true) {
150
184
  this._projection.layers.set(2);
151
185
  }
186
+
187
+ if (this.context.scene.backgroundBlurriness > 0.001 || this._needsTextureUpdate) {
188
+ this.updateBlurriness();
189
+ }
190
+
191
+ this._needsTextureUpdate = false;
152
192
  }
153
193
 
154
194
  private _blurrynessShader: ShaderMaterial | null = null;
@@ -161,12 +201,13 @@
161
201
  else if (!this.context.scene.environment) {
162
202
  return;
163
203
  }
164
-
204
+ this._needsTextureUpdate = false;
165
205
  if (debug) console.log("Update Blurriness", this.context.scene.backgroundBlurriness);
166
206
  this._blurrynessShader ??= new ShaderMaterial({
167
207
  uniforms: {
168
208
  map: { value: this.context.scene.environment },
169
- blurriness: { value: this.context.scene.backgroundBlurriness }
209
+ blurriness: { value: this.context.scene.backgroundBlurriness },
210
+ blending: { value: 0 }
170
211
  },
171
212
  vertexShader: vertexShader,
172
213
  fragmentShader: fragmentShader
@@ -174,6 +215,21 @@
174
215
  this._blurrynessShader.depthWrite = false;
175
216
  this._blurrynessShader.uniforms.blurriness.value = this.context.scene.backgroundBlurriness;
176
217
  this._lastBlurriness = this.context.scene.backgroundBlurriness;
218
+ this.context.scene.environment.needsPMREMUpdate = true;
219
+
220
+ const wasTransparent = this._projection.material.transparent;
221
+ this._projection.material.transparent = (this.context.xr?.isAR === true && this.arBlending > 0.000001) ?? false;
222
+ if (this._projection.material.transparent) {
223
+ this._blurrynessShader.uniforms.blending.value = this.arBlending;
224
+ }
225
+ else { this._blurrynessShader.uniforms.blending.value = 0; }
226
+
227
+ // Make sure the material is updated if the transparency changed
228
+ if (wasTransparent !== this._projection.material.transparent) {
229
+ this._projection.material.needsUpdate = true;
230
+ }
231
+
232
+ // Update the texture
177
233
  this._projection.material.map = Graphics.copyTexture(this.context.scene.environment, this._blurrynessShader);
178
234
  }
179
235
 
@@ -193,6 +249,7 @@
193
249
  const fragmentShader = `
194
250
  uniform sampler2D map;
195
251
  uniform float blurriness;
252
+ uniform float blending;
196
253
  varying vec2 vUv;
197
254
 
198
255
  const float PI = 3.14159265359;
@@ -212,10 +269,10 @@
212
269
  vec2 center = vec2(0.0, 0.0);
213
270
  vec2 pos = vUv;
214
271
  pos.x = 0.0; // Only consider vertical distance
215
- float distance = length(pos - center) * 2.0;
272
+ float distance = length(pos - center);
216
273
 
217
274
  // Calculate blur amount based on custom falloff
218
- float blurAmount = customSmoothstep(0.5, 1.0, distance);
275
+ float blurAmount = customSmoothstep(0.5, 1.0, distance * 2.0);
219
276
  blurAmount = clamp(blurAmount, 0.0, 1.0); // Ensure blur amount is within valid range
220
277
 
221
278
  // Gaussian blur
@@ -238,6 +295,11 @@
238
295
 
239
296
  gl_FragColor = color;
240
297
 
298
+ float brightness = dot(gl_FragColor.rgb, vec3(0.299, 0.587, 0.114));
299
+ float stepFactor = blending - brightness * .1;
300
+ gl_FragColor.a = pow(1.0 - blending * customSmoothstep(0.35 * stepFactor, 0.45 * stepFactor, distance), 5.);
301
+ // gl_FragColor.rgb = vec3(1.0);
302
+
241
303
  // #include <tonemapping_fragment>
242
304
  // #include <colorspace_fragment>
243
305
 
src/engine/webcomponents/needle menu/needle-menu.ts CHANGED
@@ -359,6 +359,27 @@
359
359
  flex-direction: column-reverse;
360
360
  }
361
361
 
362
+ .compact .options {
363
+ max-height: 20ch;
364
+ overflow: auto;
365
+ flex-direction: row;
366
+ flex-wrap: wrap;
367
+ align-items: center;
368
+ }
369
+
370
+ ::-webkit-scrollbar {
371
+ max-width: 7px;
372
+ background: rgba(100,100,100,.2);
373
+ border-radius: .2rem;
374
+ }
375
+ ::-webkit-scrollbar-thumb {
376
+ background: rgba(255, 255, 255, .3);
377
+ border-radius: .2rem;
378
+ }
379
+ ::-webkit-scrollbar-thumb:hover {
380
+ background: rgb(150,150,150);
381
+ }
382
+
362
383
  .compact .options > * {
363
384
  font-size: 1.2rem;
364
385
  padding: .6rem .5rem;
src/engine/xr/NeedleXRController.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { fetchProfile, MotionController } from "@webxr-input-profiles/motion-controllers";
2
- import { AxesHelper, Int8BufferAttribute, Object3D, Quaternion, Ray, Vector3 } from "three";
2
+ import { AxesHelper, Int8BufferAttribute, Matrix4, Object3D, Quaternion, Ray, Vector3 } from "three";
3
3
 
4
4
  import { RGBAColor } from "../../engine-components/js-extensions/RGBAColor.js";
5
5
  import { Context } from "../engine_context.js";
@@ -131,9 +131,24 @@
131
131
 
132
132
  /** The XRTransientInputHitTestSource can be used to perform hit tests with the controller ray against the real world.
133
133
  * see https://developer.mozilla.org/en-US/docs/Web/API/XRSession/requestHitTestSourceForTransientInput for more information
134
- * Requires the hit-test feature to be enabled in the XRSession
134
+ * Requires the hit-test feature to be enabled in the XRSession
135
+ *
136
+ * NOTE: The hit test source should be cancelled once it's not needed anymore. Call `cancelHitTestSource` to do this
135
137
  */
136
- get hitTestSource() { return this._hitTestSource; }
138
+ getHitTestSource() {
139
+ if (!this._hitTestSource) this._requestHitTestSource();
140
+ return this._hitTestSource;
141
+ }
142
+ get hasHitTestSource() {
143
+ return this._hitTestSource;
144
+ }
145
+ /** Make sure to cancel the hittest source once it's not needed anymore */
146
+ cancelHitTestSource() {
147
+ if (this._hitTestSource) {
148
+ this._hitTestSource.cancel();
149
+ this._hitTestSource = undefined;
150
+ }
151
+ }
137
152
  private _hitTestSource: XRTransientInputHitTestSource | undefined = undefined;
138
153
  private _hasSelectEvent = false;
139
154
  get hasSelectEvent() { return this._hasSelectEvent; }
@@ -157,16 +172,28 @@
157
172
  return pose;
158
173
  }
159
174
 
175
+ /** Grip matrix in grip space */
176
+ private readonly _gripMatrix = new Matrix4();
177
+ /** Grip position in grip space */
160
178
  private readonly _gripPosition = new Vector3();
179
+ /** Grip rotation in grip space */
161
180
  private readonly _gripQuaternion = new Quaternion();
162
181
  private readonly _linearVelocity: Vector3 = new Vector3();
182
+
183
+ private readonly _rayPositionRaw = new Vector3();
184
+ private readonly _rayRotationRaw = new Quaternion();
185
+ /** ray matrix in grip space */
186
+ private readonly _rayMatrix = new Matrix4();
187
+ /** Ray position in rig space */
163
188
  private readonly _rayPosition = new Vector3();
189
+ /** Ray rotation in rig space */
164
190
  private readonly _rayQuaternion = new Quaternion();
165
191
 
166
192
  /** Grip position in rig space */
167
- get gripPosition() { return getTempVector(this._gripPosition).applyMatrix4(flipForwardMatrix) }
193
+ get gripPosition() { return getTempVector(this._gripPosition) }
168
194
  /** Grip rotation in rig space */
169
- get gripQuaternion() { return getTempQuaternion(this._gripQuaternion).premultiply(flipForwardQuaternion) }
195
+ get gripQuaternion() { return getTempQuaternion(this._gripQuaternion) }
196
+ get gripMatrix() { return this._gripMatrix; }
170
197
  /** Grip linear velocity in rig space
171
198
  * @link https://developer.mozilla.org/en-US/docs/Web/API/XRPose/linearVelocity
172
199
  */
@@ -174,14 +201,12 @@
174
201
  return getTempVector(this._linearVelocity).applyQuaternion(flipForwardQuaternion);
175
202
  }
176
203
  /** Ray position in rig space */
177
- get rayPosition() { return getTempVector(this._rayPosition).applyMatrix4(flipForwardMatrix) }
204
+ get rayPosition() { return getTempVector(this._rayPosition) }
178
205
  /** Ray rotation in rig space */
179
- get rayQuaternion() { return getTempQuaternion(this._rayQuaternion).premultiply(flipForwardQuaternion) }
206
+ get rayQuaternion() { return getTempQuaternion(this._rayQuaternion) }
180
207
 
181
208
  /** Controller grip position in worldspace */
182
- get gripWorldPosition() {
183
- return getTempVector(this._gripWorldPosition);
184
- }
209
+ get gripWorldPosition() { return getTempVector(this._gripWorldPosition); }
185
210
  private readonly _gripWorldPosition: Vector3 = new Vector3();
186
211
 
187
212
  /** Controller grip rotation in wordspace */
@@ -198,7 +223,7 @@
198
223
  /** Recalculates the ray world position */
199
224
  updateRayWorldPosition() {
200
225
  const parent = this.xr.context.mainCamera?.parent;
201
- this._rayWorldPosition.copy(this._rayPosition);
226
+ this._rayWorldPosition.copy(this._rayPositionRaw);
202
227
  if (parent) this._rayWorldPosition.applyMatrix4(parent.matrixWorld);
203
228
  }
204
229
 
@@ -211,7 +236,7 @@
211
236
  updateRayWorldQuaternion() {
212
237
  const parent = this.xr.context.mainCamera?.parent;
213
238
  const parentWorldQuaternion = parent ? getWorldQuaternion(parent) : undefined;
214
- this._rayWorldQuaternion.copy(this._rayQuaternion)
239
+ this._rayWorldQuaternion.copy(this._rayRotationRaw)
215
240
  // flip forward because we want +Z to be forward
216
241
  .multiply(flipForwardQuaternion);
217
242
  if (parentWorldQuaternion) this._rayWorldQuaternion.premultiply(parentWorldQuaternion)
@@ -268,16 +293,30 @@
268
293
  this.initialize();
269
294
  this.subscribeEvents();
270
295
 
271
- // TODO: change this to check if we have hit-testing enabled instead of pass through.
272
- if (this.xr.mode === "immersive-ar" && this.inputSource.targetRayMode === "tracked-pointer") {
296
+ }
297
+
298
+ private _hitTestSourcePromise: Promise<XRTransientInputHitTestSource | null> | null = null;
299
+ private _requestHitTestSource(): Promise<XRTransientInputHitTestSource | null> | null {
300
+ if (this._hitTestSourcePromise) return this._hitTestSourcePromise;
301
+ // We only request a hit test source when we need it - meaning e.g. when we want to place the scene in AR
302
+ // Make sure to cancel the hittest source when we don't need it anymore for performance reasons
303
+
304
+ // // TODO: change this to check if we have hit-testing enabled instead of pass through.
305
+ if (this.xr.mode === "immersive-ar" && this.inputSource.targetRayMode === "tracked-pointer" && this.xr.session.requestHitTestSourceForTransientInput) {
273
306
  // request hittest source
274
- this.xr.session.requestHitTestSourceForTransientInput?.({
307
+ return this._hitTestSourcePromise = this.xr.session.requestHitTestSourceForTransientInput({
275
308
  profile: this.inputSource.profiles[0],
276
309
  offsetRay: new XRRay(),
277
310
  })?.then(hitTestSource => {
311
+ this._hitTestSourcePromise = null;
312
+ if (!this.connected) {
313
+ hitTestSource.cancel();
314
+ return null;
315
+ }
278
316
  return this._hitTestSource = hitTestSource;
279
- });
317
+ }) ?? null;
280
318
  }
319
+ return null;
281
320
  }
282
321
 
283
322
  onPointerHits = _evt => {
@@ -312,19 +351,34 @@
312
351
 
313
352
  const rayPose = frame.getPose(this.inputSource.targetRaySpace, this.xr.referenceSpace);
314
353
  this._isTracking = rayPose != null;
354
+ let gripPositionRaw: Vector3 | null = null;
355
+ let gripQuaternionRaw: Quaternion | null = null;
356
+ let rayPositionRaw: Vector3 | null = null;
357
+ let rayQuaternionRaw: Quaternion | null = null;
315
358
 
316
359
  if (rayPose) {
317
360
  const t = rayPose.transform;
318
- this._rayPosition.set(t.position.x, t.position.y, t.position.z);
319
- this._rayQuaternion.set(t.orientation.x, t.orientation.y, t.orientation.z, t.orientation.w);
361
+ this._rayMatrix
362
+ .fromArray(t.matrix)
363
+ .premultiply(flipForwardMatrix);
364
+ this._rayMatrix.decompose(this._rayPosition, this._rayQuaternion, getTempVector(1, 1, 1));
365
+ rayPositionRaw = getTempVector(t.position);
366
+ rayQuaternionRaw = getTempQuaternion(t.orientation);
367
+ this._rayPositionRaw.copy(t.position);
368
+ this._rayRotationRaw.copy(t.orientation);
320
369
  }
321
370
 
322
371
  if (this.inputSource.gripSpace) {
323
372
  const gripPose = frame.getPose(this.inputSource.gripSpace, this.xr.referenceSpace!);
324
373
  if (gripPose) {
325
374
  const t = gripPose.transform;
326
- this._gripPosition.set(t.position.x, t.position.y, t.position.z);
327
- this._gripQuaternion.set(t.orientation.x, t.orientation.y, t.orientation.z, t.orientation.w);
375
+ gripPositionRaw = getTempVector(t.position);
376
+ gripQuaternionRaw = getTempQuaternion(t.orientation);
377
+ this._gripMatrix
378
+ .fromArray(t.matrix)
379
+ .premultiply(flipForwardMatrix);
380
+ this._gripMatrix.decompose(this._gripPosition, this._gripQuaternion, getTempVector(1, 1, 1));
381
+
328
382
  if ("linearVelocity" in gripPose && gripPose.linearVelocity) {
329
383
  const p = gripPose.linearVelocity as DOMPointReadOnly;
330
384
  this._linearVelocity.set(p.x, p.y, p.z);
@@ -362,22 +416,22 @@
362
416
  const middle = hand.get("middle-finger-metacarpal");
363
417
  const middlePose = middle && this.getHandJointPose(middle);
364
418
  if (middlePose) {
365
- const p = middlePose.transform.position;
366
- const q = middlePose.transform.orientation;
367
419
  // for some reason the grip rotation is different from the wrist rotation
368
420
  // but we want to use the wrist rotation for the grip
369
- this._gripPosition.set(p.x, p.y, p.z);
370
- this._gripQuaternion.set(q.x, q.y, q.z, q.w);
421
+ this._gripMatrix
422
+ .fromArray(middlePose.transform.matrix)
423
+ .premultiply(flipForwardMatrix);
424
+ this._gripMatrix.decompose(this._gripPosition, this._gripQuaternion, getTempVector(1, 1, 1));
371
425
  }
372
426
  }
373
427
  // on VisionOS we get a gripSpace that matches where the controller is for transient input sources
374
- else if (this.inputSource.gripSpace && this.targetRayMode === "transient-pointer") {
375
- this._object.position.copy(this._gripPosition);
376
- this._object.quaternion.copy(this._gripQuaternion).multiply(flipForwardQuaternion);
428
+ else if (this.inputSource.gripSpace && this.targetRayMode === "transient-pointer" && gripPositionRaw && gripQuaternionRaw) {
429
+ this._object.position.copy(gripPositionRaw);
430
+ this._object.quaternion.copy(gripQuaternionRaw).multiply(flipForwardQuaternion);
377
431
  }
378
- else {
379
- this._object.position.copy(this._rayPosition);
380
- this._object.quaternion.copy(this._rayQuaternion).multiply(flipForwardQuaternion);
432
+ else if (rayPositionRaw && rayQuaternionRaw) {
433
+ this._object.position.copy(rayPositionRaw);
434
+ this._object.quaternion.copy(rayQuaternionRaw).multiply(flipForwardQuaternion);
381
435
  }
382
436
 
383
437
 
@@ -386,12 +440,15 @@
386
440
  const parentWorldQuaternion = parent ? getWorldQuaternion(parent) : undefined;
387
441
 
388
442
  // GRIP
389
- this._gripWorldPosition.copy(this._gripPosition);
390
- if (parent) this._gripWorldPosition.applyMatrix4(parent.matrixWorld);
391
- this._gripWorldQuaternion.copy(this._gripQuaternion);
392
- // flip forward because we want +Z to be forward
393
- this._gripWorldQuaternion.multiply(flipForwardQuaternion);
394
- if (parentWorldQuaternion) this._gripWorldQuaternion.premultiply(parentWorldQuaternion)
443
+ if (gripPositionRaw && gripQuaternionRaw) {
444
+ this._gripWorldPosition.copy(gripPositionRaw);
445
+ if (parent) this._gripWorldPosition.applyMatrix4(parent.matrixWorld);
446
+
447
+ this._gripWorldQuaternion.copy(gripQuaternionRaw);
448
+ // flip forward because we want +Z to be forward
449
+ this._gripWorldQuaternion.multiply(flipForwardQuaternion);
450
+ if (parentWorldQuaternion) this._gripWorldQuaternion.premultiply(parentWorldQuaternion)
451
+ }
395
452
 
396
453
  // RAY
397
454
  this.updateRayWorldPosition();
@@ -409,6 +466,10 @@
409
466
  this._object.removeFromParent();
410
467
  this._debugAxesHelper.removeFromParent();
411
468
  this.unsubscribeEvents();
469
+ if (this._hitTestSource) {
470
+ this._hitTestSource.cancel();
471
+ this._hitTestSource = undefined;
472
+ }
412
473
  }
413
474
 
414
475
  /**
@@ -471,7 +532,7 @@
471
532
  private readonly _needleGamepadButtons: { [key: number | string]: NeedleGamepadButton } = {};
472
533
  /** combine the InputState information + the GamepadButton information (since GamepadButtons can not be extended) */
473
534
  private toNeedleGamepadButton(index: number): NeedleGamepadButton | undefined {
474
- if(!this.inputSource.gamepad?.buttons) return undefined
535
+ if (!this.inputSource.gamepad?.buttons) return undefined
475
536
  const button = this.inputSource.gamepad?.buttons[index];
476
537
  const state = this.states[index];
477
538
  const needleButton = this._needleGamepadButtons[index] || new NeedleGamepadButton();
src/engine/xr/NeedleXRSession.ts CHANGED
@@ -534,12 +534,22 @@
534
534
  * @link https://developer.mozilla.org/en-US/docs/Web/API/XRSession/visibilityState
535
535
  * @returns {XRVisibilityState} The visibility state of the XRSession
536
536
  */
537
- get visibilityState() { return this.session.visibilityState; }
537
+ get visibilityState(): XRVisibilityState { return this.session.visibilityState; }
538
538
 
539
539
  /**
540
+ * Check if the session is `visible-blurred` - this means e.g. the keyboard is shown
541
+ */
542
+ get isVisibleBlurred(): boolean { return this.session.visibilityState === 'visible-blurred' }
543
+
544
+ /**
545
+ * Check if the session has system keyboard support
546
+ */
547
+ get isSystemKeyboardSupported(): boolean { return this.session.isSystemKeyboardSupported; }
548
+
549
+ /**
540
550
  * @link https://developer.mozilla.org/en-US/docs/Web/API/XRSession/environmentBlendMode
541
551
  */
542
- get environmentBlendMode() { return this.session.environmentBlendMode; }
552
+ get environmentBlendMode(): XREnvironmentBlendMode { return this.session.environmentBlendMode; }
543
553
 
544
554
  /**
545
555
  * The current XR frame
@@ -718,7 +728,7 @@
718
728
  return null;
719
729
  }
720
730
  private getControllerHitTest(controller: NeedleXRController): NeedleXRHitTestResult | null {
721
- const hitTestSource = controller.hitTestSource;
731
+ const hitTestSource = controller.getHitTestSource();
722
732
  if (!hitTestSource) return null;
723
733
  const res = this.frame.getHitTestResultsForTransientInput(hitTestSource);
724
734
  for (const result of res) {
@@ -852,10 +862,31 @@
852
862
  }
853
863
  });
854
864
 
865
+ // Unfortunately the code below doesnt work: the session never receives any input sources sometimes
866
+ // https://developer.mozilla.org/en-US/docs/Web/API/XRSession/visibilitychange_event
867
+ // this.session.addEventListener("visibilitychange", (evt: XRSessionEvent) => {
868
+ // // sometimes when entering an XR session the controllers are not added/not in the list and we don't receive an event
869
+ // // this is a workaround trying to add controllers when the scene visibility changes to "visible"
870
+ // // e.g. due to a user opening and closing the menu
871
+ // if (this.controllers.length === 0 && evt.session.visibilityState === "visible") {
872
+ // for (const controller of evt.session.inputSources) {
873
+ // this.onInputSourceAdded(controller);
874
+ // }
875
+ // }
876
+ // })
877
+
855
878
  // we set the session on the webxr manager at the end because we want to receive inputsource events first
856
879
  // e.g. in case there's a bug in the threejs codebase
857
880
  this.context.xr = this;
858
881
  this.context.renderer.xr.setSession(this.session).then(this.onRendererSessionSet);
882
+ // disable three.js renderer controller autoUpdate (added in ac67b31e3548386f8a93e23a4176554c92bbd0d9)
883
+ if ("controllerAutoUpdate" in this.context.renderer.xr) {
884
+ console.debug("Disabling three.js controllerAutoUpdate");
885
+ this.context.renderer.xr.controllerAutoUpdate = false;
886
+ }
887
+ else if (debug) {
888
+ console.warn("controllerAutoUpdate is not available in three.js - cannot disable it");
889
+ }
859
890
  }
860
891
 
861
892
  /** called when renderer.setSession is fulfilled */
@@ -1133,7 +1164,10 @@
1133
1164
  const copy = [...this._newControllers];
1134
1165
  this._newControllers.length = 0;
1135
1166
  for (const controller of copy) {
1136
- if (!controller.connected) continue;
1167
+ if (!controller.connected) {
1168
+ console.warn("New controller is not connected", controller);
1169
+ continue;
1170
+ }
1137
1171
  this.controllers.push(controller);
1138
1172
  for (const script of this._xr_scripts) {
1139
1173
  if (script.destroyed) {
@@ -1151,6 +1185,11 @@
1151
1185
  this.controllers.sort((a, b) => a.index - b.index);
1152
1186
  }
1153
1187
 
1188
+ if (debug && this.context.time.frame % 30 === 0 && this.controllers.length <= 0 && this.session.inputSources.length > 0) {
1189
+ enableSpatialConsole(true)
1190
+ console.error("XRControllers are not added but inputSources are present");
1191
+ }
1192
+
1154
1193
  // invoke update on all scripts
1155
1194
  for (const script of this._xr_update_scripts) {
1156
1195
  if (script.destroyed === true) {
@@ -1198,10 +1237,11 @@
1198
1237
  const upwards = this.rig.gameObject.worldUp;
1199
1238
  pos.add(upwards.multiplyScalar(2.5));
1200
1239
  let debugLabel = "";
1201
- debugLabel += this.context.time.smoothedFps.toFixed(1);
1202
- if (debug) {
1240
+ debugLabel += `${this.context.time.smoothedFps.toFixed(0)} FPS`;
1241
+ debugLabel += `, calls: ${this.context.renderer.info.render.calls}, tris: ${this.context.renderer.info.render.triangles.toLocaleString()}`;
1242
+ if (debug || debugFPS) {
1203
1243
  for (const ctrl of this.controllers) {
1204
- debugLabel += `\n${ctrl.hand ? "hand" : "ctrl"} ${ctrl.inputSource.handedness}[${ctrl.index}] con:${ctrl.connected} tr:${ctrl.isTracking}`;
1244
+ debugLabel += `\n${ctrl.hand ? "hand" : "ctrl"} ${ctrl.inputSource.handedness}[${ctrl.index}] con:${ctrl.connected} tr:${ctrl.isTracking} hts:${ctrl.hasHitTestSource ? "yes" : "no"}`;
1205
1245
  }
1206
1246
  }
1207
1247
  Gizmos.DrawLabel(pos, debugLabel);
src/engine-components/webxr/WebARSessionRoot.ts CHANGED
@@ -385,6 +385,12 @@
385
385
  if (this.useXRAnchor) {
386
386
  this.onCreateAnchor(NeedleXRSession.active!, hit);
387
387
  }
388
+
389
+ if (this.context.xr) {
390
+ for (const ctrl of this.context.xr.controllers) {
391
+ ctrl.cancelHitTestSource();
392
+ }
393
+ }
388
394
  }
389
395
 
390
396
  private onScaleChanged() {
src/engine-components/webxr/controllers/XRControllerModel.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  import { registerExtensions } from "../../../engine/extensions/extensions.js";
16
16
  import { NEEDLE_progressive } from "../../../engine/extensions/NEEDLE_progressive.js";
17
17
  import { Behaviour, GameObject } from "../../Component.js"
18
+ import { flipForwardMatrix } from "../../../engine/xr/internal.js";
18
19
 
19
20
  const debug = getParam("debugwebxr");
20
21
 
@@ -97,6 +98,10 @@
97
98
  // The controller mesh should by default inherit layers.
98
99
  model.traverse(child => {
99
100
  child.layers.set(2);
101
+ // disable auto update on controller objects. No need to do this every frame
102
+ child.matrixWorldAutoUpdate = false;
103
+ child.matrixAutoUpdate = false;
104
+ child.updateMatrix();
100
105
  });
101
106
  controller.model = model;
102
107
  }
@@ -168,11 +173,9 @@
168
173
 
169
174
  // do we have a controller model?
170
175
  if (entry.model && !entry.handmesh) {
171
- // TODO: if the model would just be in the XR rig, we could just set grip position and we would not need to override the scale
172
- // entry.model.position.copy(ctrl.gripWorldPosition);
173
- entry.model.position.copy(ctrl.gripPosition);
174
- // entry.model.quaternion.copy(ctrl.gripWorldQuaternion);
175
- entry.model.quaternion.copy(ctrl.gripQuaternion);
176
+ entry.model.matrixAutoUpdate = false;
177
+ entry.model.matrixWorldAutoUpdate = false;
178
+ entry.model.matrix.copy(ctrl.gripMatrix);
176
179
  entry.model.visible = ctrl.isTracking;
177
180
  // ensure that controller models are in rig space
178
181
  xr.rig?.gameObject.add(entry.model);
@@ -203,12 +206,11 @@
203
206
  // Update the joints groups with the XRJoint poses
204
207
  const jointPose = ctrl.getHandJointPose(inputjoint);
205
208
  if (jointPose) {
206
- // joint.matrixAutoUpdate = false;
207
- // joint.matrix.fromArray(jointPose.transform.matrix);
208
- // joint.matrix.decompose(joint.position, joint.quaternion, joint.scale);
209
- const { position, quaternion } = xr.convertSpace(jointPose.transform);
209
+ const position = jointPose.transform.position;
210
+ const quaternion = jointPose.transform.orientation;
210
211
  joint.position.copy(position);
211
212
  joint.quaternion.copy(quaternion);
213
+ joint.matrixAutoUpdate = false;
212
214
  joint.matrixWorldAutoUpdate = false;
213
215
  }
214
216
  joint.visible = jointPose != null;
@@ -220,10 +222,15 @@
220
222
  if (entry.model.visible && entry.model.parent !== xr.rig?.gameObject) {
221
223
  xr.rig?.gameObject.add(entry.model);
222
224
  }
223
- entry.model.position.set(0, 0, 0);
224
225
  }
225
226
 
226
- if (entry.model?.visible) entry.handmesh?.updateMesh();
227
+ if (entry.model?.visible) {
228
+ entry.handmesh?.updateMesh();
229
+ entry.model.matrixWorldAutoUpdate = false;
230
+ entry.model.matrixAutoUpdate = false;
231
+ entry.model.matrix.identity();
232
+ entry.model.applyMatrix4(flipForwardMatrix);
233
+ }
227
234
  }
228
235
  }
229
236
  }
src/engine-components/webxr/controllers/XRControllerMovement.ts CHANGED
@@ -62,7 +62,7 @@
62
62
  * @default false
63
63
  */
64
64
  @serializable()
65
- showHits: boolean = false;
65
+ showHits: boolean = true;
66
66
 
67
67
  readonly isXRMovementHandler: true = true;
68
68
  readonly xrSessionMode = "immersive-vr";
@@ -257,7 +257,7 @@
257
257
  continue;
258
258
  }
259
259
 
260
- const hit = this.context.physics.raycastFromRay(ctrl.ray, { testObject: this.hitPointRaycastFilter })[0];
260
+ const hit = this.context.physics.raycastFromRay(ctrl.ray, { testObject: this.hitPointRaycastFilter, precise: false })[0];
261
261
  this._hitDistances[i] = hit?.distance;
262
262
 
263
263
  let disc = this._hitDiscs[i];