Needle Engine

Changes between version 3.46.0-beta.4 and 3.46.0-beta.5
Files changed (8) hide show
  1. src/engine-components/DropListener.ts +1 -1
  2. src/engine/webcomponents/needle menu/needle-menu.ts +24 -4
  3. src/engine/xr/NeedleXRController.ts +27 -23
  4. src/engine/xr/NeedleXRSession.ts +3 -1
  5. src/engine-components/timeline/PlayableDirector.ts +13 -2
  6. src/engine-components/SceneSwitcher.ts +35 -1
  7. src/engine-components/webxr/WebXR.ts +8 -0
  8. src/engine-components/webxr/controllers/XRControllerMovement.ts +51 -11
src/engine-components/DropListener.ts CHANGED
@@ -215,7 +215,7 @@
215
215
  // Ignore dropped images
216
216
  const lowercaseUrl = url.toLowerCase();
217
217
  if (lowercaseUrl.endsWith(".hdr") || lowercaseUrl.endsWith(".hdri") || lowercaseUrl.endsWith(".exr") || lowercaseUrl.endsWith(".png") || lowercaseUrl.endsWith(".jpg") || lowercaseUrl.endsWith(".jpeg")) {
218
- return;
218
+ return null;
219
219
  }
220
220
 
221
221
  const res = await files.addFileFromUrl(new URL(url), this.context);
src/engine/webcomponents/needle menu/needle-menu.ts CHANGED
@@ -29,9 +29,16 @@
29
29
  }
30
30
  }
31
31
 
32
- declare type ButtonInfo = {
32
+ /**
33
+ * Used by the NeedleMenuElement to create a button at {@link NeedleMenuElement#appendChild}
34
+ */
35
+ export declare type ButtonInfo = {
33
36
  label: string,
34
37
  onClick: (evt: Event) => void,
38
+ /** Material icon name: https://fonts.google.com/icons */
39
+ icon?: string,
40
+ /** Low priority is icon is on the left, high priority is icon is on the right. Default is undefined */
41
+ priority?: number;
35
42
  }
36
43
 
37
44
  export class NeedleMenu {
@@ -280,7 +287,7 @@
280
287
  font-weight: 500;
281
288
  font-weight: 200;
282
289
  font-variation-settings: "wdth" 100;
283
- color: rgb(30,30,30);
290
+ color: rgb(20,20,20);
284
291
  }
285
292
 
286
293
  a {
@@ -305,8 +312,17 @@
305
312
  cursor: pointer;
306
313
  color: black;
307
314
  background: rgba(245, 245, 245, .8);
308
- outline: rgba(0,0,0,.05) 1px solid;
315
+ box-shadow: inset 0 0 1rem rgba(0,0,30,.2);
316
+ outline: rgba(0,0,0,.1) 1px solid;
309
317
  }
318
+ :host .options > *:active, ::slotted(*:active) {
319
+ background: rgba(255, 255, 255, .8);
320
+ box-shadow: inset 0px 1px 1px rgba(255,255,255,.5), inset 0 0 2rem rgba(0,0,30,.2), inset 0px 2px 4px rgba(0,0,20,.5);
321
+ transition: all 0.05s linear;
322
+ }
323
+ :host .options > *:focus, ::slotted(*:focus) {
324
+ outline: rgba(255,255,255,.5) 1px solid;
325
+ }
310
326
 
311
327
  :host .options > *:disabled, ::slotted(*:disabled) {
312
328
  background: rgba(0,0,0,.05);
@@ -360,7 +376,7 @@
360
376
  }
361
377
 
362
378
  .compact .options {
363
- max-height: 20ch;
379
+ max-height: 18ch;
364
380
  overflow: auto;
365
381
  flex-direction: row;
366
382
  flex-wrap: wrap;
@@ -622,6 +638,10 @@
622
638
  const button = document.createElement("button");
623
639
  button.textContent = node.label;
624
640
  button.onclick = node.onClick;
641
+ button.setAttribute("priority", node.priority?.toString() ?? "0");
642
+ if (node.icon) {
643
+ button.prepend(getIconElement(node.icon));
644
+ }
625
645
  node = button as unknown as T;
626
646
  }
627
647
 
src/engine/xr/NeedleXRController.ts CHANGED
@@ -443,7 +443,7 @@
443
443
  if (gripPositionRaw && gripQuaternionRaw) {
444
444
  this._gripWorldPosition.copy(gripPositionRaw);
445
445
  if (parent) this._gripWorldPosition.applyMatrix4(parent.matrixWorld);
446
-
446
+
447
447
  this._gripWorldQuaternion.copy(gripQuaternionRaw);
448
448
  // flip forward because we want +Z to be forward
449
449
  this._gripWorldQuaternion.multiply(flipForwardQuaternion);
@@ -751,30 +751,34 @@
751
751
  const indexTip = handObject.joints["index-finger-tip"];
752
752
  const thumbTip = handObject.joints["thumb-tip"];
753
753
  if (indexTip && thumbTip) {
754
- const pinchThreshold = .02;
755
- const pinchHysteresis = .01;
756
754
  const distance = indexTip.position.distanceTo(thumbTip.position);
757
- const state = this.states["pinch"] || new InputState();
758
- state.value = distance;
759
-
760
- const isPressed = distance < (pinchThreshold - pinchHysteresis);
761
- const isReleased = distance > (pinchThreshold + pinchHysteresis);
762
- if (isPressed && !state.pressed) {
763
- if (debugCustomGesture) console.log("pinch start", distance);
764
- state.isDown = true;
765
- state.isUp = false;
766
- state.pressed = true;
755
+ if(distance !== 0) { // ignore exactly 0 which happens when we switch from hands to controllers
756
+ const pinchThreshold = .02;
757
+ const pinchHysteresis = .01;
758
+ const state = this.states["pinch"] || new InputState();
759
+ const maxDistance = (pinchThreshold + pinchHysteresis) * 1.5;
760
+ if (this.isRight) console.log(distance);
761
+ state.value = 1 - ((distance - pinchThreshold) / maxDistance);
762
+
763
+ const isPressed = distance < (pinchThreshold - pinchHysteresis);
764
+ const isReleased = distance > (pinchThreshold + pinchHysteresis);
765
+ if (isPressed && !state.pressed) {
766
+ if (debugCustomGesture) console.log("pinch start", distance);
767
+ state.isDown = true;
768
+ state.isUp = false;
769
+ state.pressed = true;
770
+ }
771
+ else if (isReleased && state.pressed) {
772
+ state.isDown = false;
773
+ state.isUp = true;
774
+ state.pressed = false;
775
+ }
776
+ else {
777
+ state.isDown = false;
778
+ state.isUp = false;
779
+ }
780
+ this.states["pinch"] = state;
767
781
  }
768
- else if (isReleased && state.pressed) {
769
- state.isDown = false;
770
- state.isUp = true;
771
- state.pressed = false;
772
- }
773
- else {
774
- state.isDown = false;
775
- state.isUp = false;
776
- }
777
- this.states["pinch"] = state;
778
782
 
779
783
  /** Workaround for visionOS not emitting selectstart. See https://linear.app/needle/issue/NE-4212
780
784
  * If a selectstart event was never received we do a manual check here if the user is pinching
src/engine/xr/NeedleXRSession.ts CHANGED
@@ -1260,9 +1260,11 @@
1260
1260
  this.onUpdateFade_PostRender();
1261
1261
 
1262
1262
  // render spectator view if we're in VR using Link
1263
- if (isDesktop()) {
1263
+ // __rendered_once is for when we are on device, but opening the browser should not show a blank space
1264
+ if (isDesktop() || !this["_renderOnceOnDevice"]) {
1264
1265
  const renderer = this.context.renderer;
1265
1266
  if (renderer.xr.isPresenting && this.context.mainCamera) {
1267
+ this["_renderOnceOnDevice"] = true;
1266
1268
  const wasXr = renderer.xr.enabled;
1267
1269
  const previousRenderTarget = renderer.getRenderTarget();
1268
1270
  const previousBackground = this.context.scene.background;
src/engine-components/timeline/PlayableDirector.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AnimationMixer, Object3D, Quaternion, Vector3 } from 'three';
2
2
 
3
+ import { isDevEnvironment } from '../../engine/debug/index.js';
3
4
  import { FrameEvent } from '../../engine/engine_context.js';
4
5
  import { isLocalNetwork } from '../../engine/engine_networking_utils.js';
5
6
  import { serializable } from '../../engine/engine_serialization.js';
@@ -107,7 +108,17 @@
107
108
 
108
109
  this.rebuildGraph();
109
110
 
110
- if (!this.isValid()) console.warn("PlayableDirector is not valid", "Asset?", this.playableAsset, "Tracks:", this.playableAsset?.tracks, "IsArray?", Array.isArray(this.playableAsset?.tracks), this);
111
+ if (!this.isValid() && (debug || isDevEnvironment())) {
112
+ if (debug) {
113
+ console.warn("PlayableDirector is not valid", "Asset?", this.playableAsset, "Tracks:", this.playableAsset?.tracks, "IsArray?", Array.isArray(this.playableAsset?.tracks), this);
114
+ }
115
+ else if (!this.playableAsset?.tracks?.length) {
116
+ console.warn("PlayableDirector has no tracks");
117
+ }
118
+ else {
119
+ console.warn("PlayableDirector is not valid");
120
+ }
121
+ }
111
122
  }
112
123
 
113
124
  /** @internal */
@@ -560,7 +571,7 @@
560
571
  if (typeof clip === "string" || typeof clip === "number") {
561
572
  clip = animationClips.find(c => c.name === targetObjectId);
562
573
  }
563
- if(debug) console.log(animModel, targetObjectId, "→", clip)
574
+ if (debug) console.log(animModel, targetObjectId, "→", clip)
564
575
  if (!clip) {
565
576
  console.warn("Could not find animationClip for model", clipModel, track.name, this.name, this.playableAsset?.name, animationClips, binding);
566
577
  continue;
src/engine-components/SceneSwitcher.ts CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  import { Addressables, AssetReference } from "../engine/engine_addressables.js";
4
4
  import { registerObservableAttribute } from "../engine/engine_element_extras.js";
5
+ import { destroy } from "../engine/engine_gameobject.js";
5
6
  import { InputEvents } from "../engine/engine_input.js";
6
7
  import { isLocalNetwork } from "../engine/engine_networking_utils.js";
7
8
  import { serializable } from "../engine/engine_serialization.js";
8
9
  import { delay, getParam, setParamWithoutReload } from "../engine/engine_utils.js";
9
10
  import { Behaviour, GameObject } from "./Component.js";
10
- import { destroy } from "../engine/engine_gameobject.js";
11
11
 
12
12
  const debug = getParam("debugsceneswitcher");
13
13
  const experimental_clearSceneOnLoad = getParam("sceneswitcher:clearscene");
@@ -196,7 +196,14 @@
196
196
  @serializable()
197
197
  preloadConcurrent: number = 2;
198
198
 
199
+ /**
200
+ * When enabled will create a button for the Needle menu to switch to the next or previous scene
201
+ * @default false
202
+ */
203
+ @serializable()
204
+ createMenuButtons: boolean = false;
199
205
 
206
+
200
207
  /** The index of the currently loaded and active scene */
201
208
  get currentIndex(): number { return this._currentIndex; }
202
209
 
@@ -220,6 +227,8 @@
220
227
 
221
228
  private _preloadScheduler?: PreLoadScheduler;
222
229
 
230
+ private _menuButtons?: HTMLElement[];
231
+
223
232
  /** @internal */
224
233
  awake(): void {
225
234
  if (this.scenes === undefined) this.scenes = [];
@@ -279,6 +288,23 @@
279
288
  // this.lock = locked;
280
289
  }
281
290
  }
291
+
292
+ // create the menu buttons
293
+ if (this.createMenuButtons) {
294
+ this._menuButtons ??= [];
295
+ this._menuButtons.push(this.context.menu.appendChild({
296
+ label: "Previous",
297
+ icon: "arrow_back_ios",
298
+ onClick: () => this.selectPrev(),
299
+ priority: -1005,
300
+ }));
301
+ this._menuButtons.push(this.context.menu.appendChild({
302
+ label: "Next",
303
+ icon: "arrow_forward_ios",
304
+ onClick: () => this.selectNext(),
305
+ priority: -1000,
306
+ }));
307
+ }
282
308
  }
283
309
 
284
310
  /** @internal */
@@ -288,6 +314,14 @@
288
314
  this.context.input.removeEventListener(InputEvents.PointerMove, this.onInputPointerMove);
289
315
  this.context.input.removeEventListener(InputEvents.PointerUp, this.onInputPointerUp);
290
316
  this._preloadScheduler?.stop();
317
+
318
+ // remove the menu buttons
319
+ if (this._menuButtons) {
320
+ for (const button of this._menuButtons) {
321
+ button.remove();
322
+ }
323
+ this._menuButtons = undefined;
324
+ }
291
325
  }
292
326
 
293
327
  private onPopState = async (_state: PopStateEvent) => {
src/engine-components/webxr/WebXR.ts CHANGED
@@ -187,6 +187,7 @@
187
187
  NeedleXRSession.stop();
188
188
  }
189
189
 
190
+ private _exitXRMenuButton?: HTMLElement;
190
191
  private _previousXRState: number = 0;
191
192
 
192
193
  private get isActiveWebXR() {
@@ -250,9 +251,16 @@
250
251
  }
251
252
 
252
253
  this.createLocalAvatar(args.xr);
254
+
255
+ this._exitXRMenuButton = this.context.menu.appendChild({
256
+ label: "Quit XR",
257
+ onClick: () => this.exitXR(),
258
+ });
253
259
  }
254
260
 
255
261
  onLeaveXR(_: NeedleXREventArgs): void {
262
+ this._exitXRMenuButton?.remove();
263
+
256
264
  if (!this.isActiveWebXR) return;
257
265
 
258
266
  // revert XR flags
src/engine-components/webxr/controllers/XRControllerMovement.ts CHANGED
@@ -12,9 +12,9 @@
12
12
  import { getParam } from "../../../engine/engine_utils.js";
13
13
  import { NeedleXRController, type NeedleXREventArgs, NeedleXRSession } from "../../../engine/engine_xr.js";
14
14
  import { Behaviour, GameObject } from "../../Component.js"
15
+ import { hasPointerEventComponent } from "../../ui/PointerEvents.js";
15
16
  import { TeleportTarget } from "../TeleportTarget.js";
16
17
  import type { XRMovementBehaviour } from "../types.js";
17
- import { hasPointerEventComponent } from "../../ui/PointerEvents.js";
18
18
 
19
19
  const debug = getParam("debugwebxr");
20
20
 
@@ -41,6 +41,13 @@
41
41
  @serializable()
42
42
  useTeleport: boolean = true;
43
43
 
44
+ /**
45
+ * When enabled you can teleport by pinching the right XR controller's index finger tip in front of the hand
46
+ * @default true
47
+ */
48
+ @serializable()
49
+ usePinchToTeleport: boolean = true;
50
+
44
51
  /** Enable to only allow teleporting on objects with a teleport target component
45
52
  * @default false
46
53
  */
@@ -142,19 +149,36 @@
142
149
  }
143
150
 
144
151
  protected onHandleTeleport(controller: NeedleXRController, rig: IGameObject) {
145
- const teleportInput = controller.getStick("xr-standard-thumbstick")
152
+ let teleportInput = 0;
153
+
154
+ if (controller.hand && this.usePinchToTeleport) {
155
+ const pinch = controller.getGesture("pinch");
156
+ if (pinch) {
157
+ teleportInput = pinch.value;
158
+ }
159
+ }
160
+ else {
161
+ teleportInput = controller.getStick("xr-standard-thumbstick")?.y;
162
+ }
163
+
146
164
  if (this._didTeleport) {
147
- if (teleportInput.y < .2) {
165
+ if (teleportInput < .4) {
148
166
  this._didTeleport = false;
149
167
  }
150
168
  }
151
- else if (teleportInput.y > .8) {
169
+ else if (teleportInput > .8) {
152
170
  this._didTeleport = true;
153
171
  const hit = this.context.physics.raycastFromRay(controller.ray)[0];
172
+
173
+ // Ignore hits on objects with interactive components if teleport target is disabled
174
+ if (hit && controller.hand && !this.useTeleportTarget) {
175
+ if (this.isObjectWithInteractiveComponent(hit.object)) return;
176
+ }
177
+
154
178
  let point: Vector3 | null = hit?.point;
155
179
 
156
180
  // If we didnt hit an object in the scene use the ground plane
157
- if (!point) {
181
+ if (!point && !this.useTeleportTarget) {
158
182
  if (!this._plane) {
159
183
  this._plane = new Plane(new Vector3(0, 1, 0), 0);
160
184
  }
@@ -189,9 +213,9 @@
189
213
 
190
214
  private _plane: Plane | null = null;
191
215
 
192
- private readonly _lines: Object3D[] = [];
216
+ private readonly _lines: Line2[] = [];
193
217
  private readonly _hitDiscs: Object3D[] = [];
194
- private readonly _hitDistances: number[] = [];
218
+ private readonly _hitDistances: Array<number | null> = [];
195
219
 
196
220
  protected renderRays(session: NeedleXRSession) {
197
221
 
@@ -223,11 +247,21 @@
223
247
  line.position.copy(pos);
224
248
  line.quaternion.copy(rot);
225
249
  const scale = session.rigScale;
226
- const dist = this._hitDistances[i] ?? scale;
250
+ const distance = this._hitDistances[i];
251
+ const hasHit = distance != null;
252
+ const dist = hasHit ? distance : scale;
227
253
  line.scale.set(scale, scale, dist);
228
254
  line.visible = true;
229
255
  line.layers.disableAll();
230
256
  line.layers.enable(2);
257
+ let targetOpacity = line.material.opacity;
258
+ if (ctrl.getButton("primary")?.pressed) {
259
+ targetOpacity = .5;
260
+ }
261
+ else {
262
+ targetOpacity = hasHit ? .2 : .1;
263
+ }
264
+ line.material.opacity = Mathf.lerp(line.material.opacity, targetOpacity, this.context.time.deltaTimeUnscaled / .1);
231
265
  if (line.parent !== this.context.scene)
232
266
  this.context.scene.add(line);
233
267
  }
@@ -261,9 +295,8 @@
261
295
  const hits = this.context.physics.raycastFromRay(ctrl.ray, { testObject: this.hitPointRaycastFilter, precise: false });
262
296
  const hit = hits.find(h => {
263
297
  // Only render hits on interactable objects
264
- return hasPointerEventComponent(h.object)
298
+ return this.isObjectWithInteractiveComponent(h.object);
265
299
  });
266
- this._hitDistances[i] = hit?.distance || 0;
267
300
 
268
301
  let disc = this._hitDiscs[i];
269
302
  if (disc) // save the hit object on the disc
@@ -271,8 +304,10 @@
271
304
  disc["controller"] = ctrl;
272
305
  disc["hit"] = hit;
273
306
  }
307
+ this._hitDistances[i] = hit?.distance || null;
274
308
 
275
309
  if (hit) {
310
+
276
311
  const rigScale = (session.rigScale ?? 1);
277
312
  if (debug) {
278
313
  Gizmos.DrawWireSphere(hit.point, .025 * rigScale, 0xff0000);
@@ -310,6 +345,11 @@
310
345
  }
311
346
  }
312
347
  }
348
+
349
+ private isObjectWithInteractiveComponent(object: Object3D) {
350
+ return hasPointerEventComponent(object) || (object["isUI"] === true || object.parent?.["isUI"] === true)
351
+ }
352
+
313
353
  private hitPointerSetPosition(ctrl: NeedleXRController, disc: Object3D, distance: number) {
314
354
  disc.position.copy(ctrl.rayWorldPosition)
315
355
  disc.position.add(getTempVector(0, 0, distance - .01).applyQuaternion(ctrl.rayWorldQuaternion));
@@ -381,7 +421,7 @@
381
421
  // TODO: this doesnt work with passthrough
382
422
  blending: AdditiveBlending,
383
423
  dashed: false,
384
- alphaToCoverage: true,
424
+ // alphaToCoverage: true,
385
425
 
386
426
  });
387
427
  line.material = mat;