Skip to content

Commit c178bc9

Browse files
author
Julia Schwarz
authored
Merge pull request #6870 from julenka/issue/6634
Fix hand rays turning off when near a grabbable that's not visible
2 parents 605ff8b + 3585070 commit c178bc9

File tree

5 files changed

+193
-7
lines changed

5 files changed

+193
-7
lines changed

Assets/MixedRealityToolkit.SDK/Features/UX/Scripts/Pointers/SpherePointer.cs

Lines changed: 132 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License. See LICENSE in the project root for license information.
33

4-
using System;
4+
using System.Collections.Generic;
55
using Microsoft.MixedReality.Toolkit.Physics;
66
using Microsoft.MixedReality.Toolkit.Utilities;
77
using UnityEngine;
@@ -65,6 +65,20 @@ public class SpherePointer : BaseControllerPointer, IMixedRealityNearPointer
6565
/// </summary>
6666
public int SceneQueryBufferSize => sceneQueryBufferSize;
6767

68+
[SerializeField]
69+
[Tooltip("Whether to ignore colliders that may be near the pointer, but not actually in the visual FOV. This can prevent accidental grabs, and will allow hand rays to turn on when you may be near a grabbable but cannot see it. Visual FOV is defined by cone centered about display center, radius equal to half display height.")]
70+
private bool ignoreCollidersNotInFOV = true;
71+
/// <summary>
72+
/// Whether to ignore colliders that may be near the pointer, but not actually in the visual FOV.
73+
/// This can prevent accidental grabs, and will allow hand rays to turn on when you may be near
74+
/// a grabbable but cannot see it. Visual FOV is defined by cone centered about display center,
75+
/// radius equal to half display height.
76+
/// </summary>
77+
public bool IgnoreCollidersNotInFOV
78+
{
79+
get => ignoreCollidersNotInFOV;
80+
set => ignoreCollidersNotInFOV = value;
81+
}
6882

6983
private SpherePointerQueryInfo queryBufferNearObjectRadius;
7084
private SpherePointerQueryInfo queryBufferInteractionRadius;
@@ -124,15 +138,15 @@ public override void OnPreSceneQuery()
124138

125139
for (int i = 0; i < layerMasks.Length; i++)
126140
{
127-
if (queryBufferNearObjectRadius.TryUpdateQueryBufferForLayerMask(layerMasks[i], pointerPosition, triggerInteraction))
141+
if (queryBufferNearObjectRadius.TryUpdateQueryBufferForLayerMask(layerMasks[i], pointerPosition, triggerInteraction, ignoreCollidersNotInFOV))
128142
{
129143
break;
130144
}
131145
}
132146

133147
for (int i = 0; i < layerMasks.Length; i++)
134148
{
135-
if (queryBufferInteractionRadius.TryUpdateQueryBufferForLayerMask(layerMasks[i], pointerPosition, triggerInteraction))
149+
if (queryBufferInteractionRadius.TryUpdateQueryBufferForLayerMask(layerMasks[i], pointerPosition, triggerInteraction, ignoreCollidersNotInFOV))
136150
{
137151
break;
138152
}
@@ -213,6 +227,15 @@ public bool TryGetNormalToNearestSurface(out Vector3 normal)
213227
/// </summary>
214228
private class SpherePointerQueryInfo
215229
{
230+
// List of corners shared across all sphere pointer query instances --
231+
// used to store list of corners for a bounds. Shared and static
232+
// to avoid allocating memory each frame
233+
private static List<Vector3> corners = new List<Vector3>();
234+
// Help to clear caches when new frame runs
235+
static private int lastCalculatedFrame = -1;
236+
// Map from grabbable => is the grabbable in FOV for this frame. Cleared every frame
237+
private static Dictionary<Collider, bool> colliderCache = new Dictionary<Collider, bool>();
238+
216239
/// <summary>
217240
/// How many colliders are near the point from the latest call to TryUpdateQueryBufferForLayerMask
218241
/// </summary>
@@ -240,7 +263,18 @@ public SpherePointerQueryInfo(int bufferSize, float radius)
240263
queryRadius = radius;
241264
}
242265

243-
public bool TryUpdateQueryBufferForLayerMask(LayerMask layerMask, Vector3 pointerPosition, QueryTriggerInteraction triggerInteraction)
266+
/// <summary>
267+
/// Intended to be called once per frame, this method performs a sphere intersection test against
268+
/// all colliders in the layers defined by layerMask at the given pointer position.
269+
/// All colliders intersecting the sphere at queryRadius and pointerPosition are stored in queryBuffer,
270+
/// and the first grabbable in the list of returned colliders is stored.
271+
/// Also provides an option to ignore colliders that are not visible.
272+
/// </summary>
273+
/// <param name="layerMask">Filter to only perform sphere cast on these layers.</param>
274+
/// <param name="pointerPosition">The position of the pointer to query against.</param>
275+
/// <param name="triggerInteraction">Passed along to the OverlapSphereNonAlloc call.</param>
276+
/// <param name="ignoreCollidersNotInFOV">Whether to ignore colliders that are not visible.</param>
277+
public bool TryUpdateQueryBufferForLayerMask(LayerMask layerMask, Vector3 pointerPosition, QueryTriggerInteraction triggerInteraction, bool ignoreCollidersNotInFOV)
244278
{
245279
grabbable = null;
246280
numColliders = UnityEngine.Physics.OverlapSphereNonAlloc(
@@ -257,14 +291,107 @@ public bool TryUpdateQueryBufferForLayerMask(LayerMask layerMask, Vector3 pointe
257291

258292
for (int i = 0; i < numColliders; i++)
259293
{
260-
grabbable = queryBuffer[i].GetComponent<NearInteractionGrabbable>();
294+
Collider collider = queryBuffer[i];
295+
grabbable = collider.GetComponent<NearInteractionGrabbable>();
296+
if (grabbable != null)
297+
{
298+
if (ignoreCollidersNotInFOV)
299+
{
300+
if (!isInFOVCone(collider))
301+
{
302+
// Additional check: is grabbable in the camera frustrum
303+
// We do this so that if grabbable is not visible it is not accidentally grabbed
304+
// Also to not turn off the hand ray if hand is near a grabbable that's not actually visible
305+
grabbable = null;
306+
}
307+
}
308+
}
309+
261310
if (grabbable != null)
262311
{
263312
return true;
264313
}
265314
}
266315
return false;
267316
}
317+
318+
319+
/// <summary>
320+
/// Returns true if a collider's bounds is within the camera FOV
321+
/// </summary>
322+
/// <param name="myCollider">The collider to test</param>
323+
private bool isInFOVCone(Collider myCollider)
324+
{
325+
if (lastCalculatedFrame != Time.frameCount)
326+
{
327+
colliderCache.Clear();
328+
lastCalculatedFrame = Time.frameCount;
329+
}
330+
if (colliderCache.TryGetValue(myCollider, out bool result))
331+
{
332+
return result;
333+
}
334+
335+
corners.Clear();
336+
BoundsExtensions.GetColliderBoundsPoints(myCollider, corners, 0);
337+
338+
float xMin = float.MaxValue, yMin = float.MaxValue, zMin = float.MaxValue;
339+
float xMax = float.MinValue, yMax = float.MinValue, zMax = float.MinValue;
340+
for (int i = 0; i < corners.Count; i++)
341+
{
342+
var corner = corners[i];
343+
if (isPointInFOVCone(corner, 0))
344+
{
345+
colliderCache.Add(myCollider, true);
346+
return true;
347+
}
348+
349+
xMin = Mathf.Min(xMin, corner.x);
350+
yMin = Mathf.Min(yMin, corner.y);
351+
zMin = Mathf.Min(zMin, corner.z);
352+
xMax = Mathf.Max(xMax, corner.x);
353+
yMax = Mathf.Max(yMax, corner.y);
354+
zMax = Mathf.Max(zMax, corner.z);
355+
}
356+
357+
// edge case: check if camera is inside the entire bounds of the collider;
358+
// Consider simplifying to myCollider.bounds.Contains(CameraCache.main.transform.position)
359+
var cameraPos = CameraCache.Main.transform.position;
360+
result = xMin <= cameraPos.x && cameraPos.x <= xMax
361+
&& yMin <= cameraPos.y && cameraPos.y <= yMax
362+
&& zMin <= cameraPos.z && cameraPos.z <= zMax;
363+
colliderCache.Add(myCollider, result);
364+
return result;
365+
}
366+
367+
/// <summary>
368+
/// Returns true if a point is in the a cone inscribed into the
369+
/// Camera's frustrum. The cone is inscribed to match the vertical height of the camera's
370+
/// FOV. By default, the cone's tip is "chopped off" by an amount defined by minDist.
371+
/// The cone's height is given by maxDist.
372+
/// </summary>
373+
/// <param name="point">Point to test</param>
374+
/// <param name="coneAngleBufferDegrees">Degrees to expand the cone by.</param>
375+
/// <param name="minDist">Point must be at least this far away (along the camera forward) from camera. </param>
376+
/// <param name="maxDist">Point must be at most this far away (along camera forward) from camera. </param>
377+
private static bool isPointInFOVCone(
378+
Vector3 point,
379+
float coneAngleBufferDegrees = 0,
380+
float minDist = 0.05f,
381+
float maxDist = 100f)
382+
{
383+
Camera mainCam = CameraCache.Main;
384+
var cameraToPoint = point - mainCam.transform.position;
385+
var pointCameraDist = Vector3.Dot(mainCam.transform.forward, cameraToPoint);
386+
if (pointCameraDist < minDist || pointCameraDist > maxDist)
387+
{
388+
return false;
389+
}
390+
var verticalFOV = mainCam.fieldOfView + coneAngleBufferDegrees;
391+
var degrees = Mathf.Acos(pointCameraDist / cameraToPoint.magnitude) * Mathf.Rad2Deg;
392+
return degrees < verticalFOV * 0.5f;
393+
}
394+
268395
/// <summary>
269396
/// Returns true if any of the objects inside QueryBuffer contain a grabbable
270397
/// </summary>

Assets/MixedRealityToolkit.SDK/Inspectors/UX/Pointers/SpherePointerInspector.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ public class SpherePointerInspector : BaseControllerPointerInspector
1515
private SerializedProperty grabLayerMasks;
1616
private SerializedProperty triggerInteraction;
1717
private SerializedProperty sceneQueryBufferSize;
18+
private SerializedProperty ignoreCollidersNotInFOV;
19+
1820

1921
private bool spherePointerFoldout = true;
2022

@@ -27,6 +29,7 @@ protected override void OnEnable()
2729
nearObjectMargin = serializedObject.FindProperty("nearObjectMargin");
2830
grabLayerMasks = serializedObject.FindProperty("grabLayerMasks");
2931
triggerInteraction = serializedObject.FindProperty("triggerInteraction");
32+
ignoreCollidersNotInFOV = serializedObject.FindProperty("ignoreCollidersNotInFOV");
3033
}
3134

3235
public override void OnInspectorGUI()
@@ -46,6 +49,7 @@ public override void OnInspectorGUI()
4649
EditorGUILayout.PropertyField(nearObjectMargin);
4750
EditorGUILayout.PropertyField(triggerInteraction);
4851
EditorGUILayout.PropertyField(grabLayerMasks, true);
52+
EditorGUILayout.PropertyField(ignoreCollidersNotInFOV);
4953
}
5054
}
5155

Assets/MixedRealityToolkit.Tests/PlayModeTests/PointerTests.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,55 @@ public void TearDown()
3838

3939
#region Tests
4040

41+
/// <summary>
42+
/// Tests that sphere pointer grabs object when hand is insize a giant grabbable
43+
/// </summary>
44+
[UnityTest]
45+
public IEnumerator TestSpherePointerInsideGrabbable()
46+
{
47+
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
48+
cube.AddComponent<NearInteractionGrabbable>();
49+
var rightHand = new TestHand(Handedness.Right);
50+
yield return rightHand.Show(Vector3.zero);
51+
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
52+
53+
var spherePointer = PointerUtils.GetPointer<SpherePointer>(Handedness.Right);
54+
Assert.IsNotNull(spherePointer, "Right hand does not have a sphere pointer");
55+
Assert.IsTrue(spherePointer.IsInteractionEnabled, "Sphere pointer should be enabled because it is near grabbable cube and visible, even if inside a giant cube.");
56+
GameObject.Destroy(cube);
57+
}
58+
59+
/// <summary>
60+
/// Tests that sphere pointer behaves correctly when hand is near grabbable
61+
/// </summary>
62+
[UnityTest]
63+
public IEnumerator TestSpherePointerNearGrabbable()
64+
{
65+
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
66+
cube.AddComponent<NearInteractionGrabbable>();
67+
cube.transform.position = Vector3.forward;
68+
cube.transform.localScale = Vector3.one * 0.1f;
69+
70+
var rightHand = new TestHand(Handedness.Right);
71+
yield return rightHand.Show(Vector3.forward);
72+
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
73+
74+
var spherePointer = PointerUtils.GetPointer<SpherePointer>(Handedness.Right);
75+
Assert.IsNotNull(spherePointer, "Right hand does not have a sphere pointer");
76+
Assert.IsTrue(spherePointer.IsInteractionEnabled, "Sphere pointer should be enabled because it is near grabbable cube and visible.");
77+
78+
// Move forward so that cube is no longer visible
79+
CameraCache.Main.transform.Translate(Vector3.up * 10);
80+
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
81+
Assert.IsFalse(spherePointer.IsInteractionEnabled, "Sphere pointer should NOT be enabled because hand is near grabbable but the grabbable is not visible.");
82+
83+
// Move camera back so that cube is visible again
84+
CameraCache.Main.transform.Translate(Vector3.up * -10f);
85+
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
86+
Assert.IsTrue(spherePointer.IsInteractionEnabled, "Sphere pointer should be enabled because it is near grabbable cube and visible.");
87+
GameObject.Destroy(cube);
88+
}
89+
4190
/// <summary>
4291
/// Tests that right after being instantiated, the pointer's direction
4392
/// is in the same general direction as the forward direction of the camera
14.3 KB
Loading

Documentation/Input/Pointers.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,21 @@ Useful Poke Pointer properties:
9797

9898
The *[SpherePointer](xref:Microsoft.MixedReality.Toolkit.Input.SpherePointer)* uses [UnityEngine.Physics.OverlapSphere](https://docs.unity3d.com/ScriptReference/Physics.OverlapSphere.html) in order to identify the closest [`NearInteractionGrabbable`](xref:Microsoft.MixedReality.Toolkit.Input.NearInteractionGrabbable) object for interaction, which is useful for "grabbable" input like the `ManipulationHandler`. Similar to the [`PokePointer`](xref:Microsoft.MixedReality.Toolkit.Input.PokePointer)/[`NearInteractionTouchable`](xref:Microsoft.MixedReality.Toolkit.Input.NearInteractionTouchable) functional pair, in order to be interactable with the Sphere Pointer, the game object must contain a component that is the [`NearInteractionGrabbable`](xref:Microsoft.MixedReality.Toolkit.Input.NearInteractionGrabbable) script.
9999

100+
<img src="../../Documentation/Images/Pointers/MRTK_GrabPointer.jpg" width="400">
101+
102+
100103
Useful Sphere Pointer properties:
101104

102105
- *Sphere Cast Radius*: The radius for the sphere used to query for grabbable objects.
103106
- *Grab Layer Masks* - A prioritized array of LayerMasks to determine which possible GameObjects the pointer can interact with and the order of interaction to attempt. Note that a GameObject must also have a `NearInteractionGrabbable` to interact with a SpherePointer.
104-
105107
> [!NOTE]
106108
> The Spatial Awareness layer is disabled in the default GrabPointer prefab provided by MRTK. This is done to reduce performance impact of doing a sphere overlap query with the spatial mesh. You can enable this by modifying the GrabPointer prefab.
109+
- *Ignore Colliders Not in FOV* - Whether to ignore colliders that may be near the pointer, but not actually in the visual FOV.
110+
This can prevent accidental grabs, and will allow hand rays to turn on when you may be near
111+
a grabbable but cannot see it. The *Visual FOV* is defined via a cone instead of the the typical frustum for performance reasons. This cone is centered and oriented the same as the camera's frustum with a radius equal to half display height(or vertical FOV).
112+
113+
<img src="../../Documentation/Images/Input/Pointers/SpherePointer_VisualFOV.png" width="200">
107114

108-
<img src="../../Documentation/Images/Pointers/MRTK_GrabPointer.jpg" width="400">
109115

110116
#### Teleport pointers
111117

0 commit comments

Comments
 (0)