Skip to content

Commit b784143

Browse files
author
Julia Schwarz
authored
Merge pull request #7283 from julenka/fixfov
Fixes edge cases when checking if collider is in field of view
2 parents ee924b7 + d005217 commit b784143

File tree

5 files changed

+138
-69
lines changed

5 files changed

+138
-69
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ private bool FindClosestTouchableForLayerMask(LayerMask layerMask, out BaseNearI
210210
var touchable = collider.GetComponent<BaseNearInteractionTouchable>();
211211
if (touchable)
212212
{
213-
if (IgnoreCollidersNotInFOV && !mainCam.IsInFOVConeCached(collider))
213+
if (IgnoreCollidersNotInFOV && !mainCam.IsInFOVCached(collider))
214214
{
215215
continue;
216216
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ public bool TryUpdateQueryBufferForLayerMask(LayerMask layerMask, Vector3 pointe
290290
{
291291
if (ignoreCollidersNotInFOV)
292292
{
293-
if (!mainCam.IsInFOVConeCached(collider))
293+
if (!mainCam.IsInFOVCached(collider))
294294
{
295295
// Additional check: is grabbable in the camera frustrum
296296
// We do this so that if grabbable is not visible it is not accidentally grabbed

Assets/MixedRealityToolkit.Tests/EditModeTests/Core/Extensions/CameraExtensionTests.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,12 @@ public void TearDown()
116116
/// Test that the Camera extension method IsInFOVConeCached returns valid results for colliders whose bounds are renderable to the camera
117117
/// </summary>
118118
[Test]
119-
public void TestIsInFOVConeCached()
119+
public void TestIsInFOVCached()
120120
{
121121
for (int i = 0; i < TestColliders.Count; i++)
122122
{
123123
var test = TestColliders[i];
124-
Assert.AreEqual(test.ShouldBeInFOVCamera1, testCamera.IsInFOVConeCached(test.Collider), $"TestCollider[{i}] did not match");
124+
Assert.AreEqual(test.ShouldBeInFOVCamera1, testCamera.IsInFOVCached(test.Collider), $"TestCollider[{i}] did not match");
125125
}
126126
}
127127

@@ -130,13 +130,13 @@ public void TestIsInFOVConeCached()
130130
/// facing different directions.
131131
/// </summary>
132132
[Test]
133-
public void TestIsInFOVConeCachedSecondCamera()
133+
public void TestIsInFOVCachedSecondCamera()
134134
{
135135
for (int i = 0; i < TestColliders.Count; i++)
136136
{
137137
var test = TestColliders[i];
138-
Assert.AreEqual(test.ShouldBeInFOVCamera1, testCamera.IsInFOVConeCached(test.Collider), $"TestCollider[{i}] did not match");
139-
Assert.AreEqual(test.ShouldBeInFOVCamera2, testCamera2.IsInFOVConeCached(test.Collider), $"TestColliderSecondCamera[{i}] did not match");
138+
Assert.AreEqual(test.ShouldBeInFOVCamera1, testCamera.IsInFOVCached(test.Collider), $"TestCollider[{i}] did not match");
139+
Assert.AreEqual(test.ShouldBeInFOVCamera2, testCamera2.IsInFOVCached(test.Collider), $"TestColliderSecondCamera[{i}] did not match");
140140
}
141141
}
142142

Assets/MixedRealityToolkit.Tests/PlayModeTests/PointerTests.cs

Lines changed: 84 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -101,49 +101,38 @@ public IEnumerator TestLinePointers()
101101
}
102102

103103

104-
private IEnumerator TestPointerFieldOfViewHelper(IMixedRealityPointer myPointer, GameObject cube, TestHand testHand)
104+
/// <summary>
105+
/// Test pointers are correctly enabled when interacting with colliders that are visible, but whose
106+
/// bounds are outside the camera FOV.
107+
/// </summary>
108+
[UnityTest]
109+
public IEnumerator TestPointerFOVLargeCollider()
105110
{
106-
cube.transform.SetPositionAndRotation(Vector3.forward * 1f, Quaternion.identity);
107-
cube.transform.localScale = Vector3.one * 0.1f;
108-
yield return testHand.MoveTo(cube.transform.position);
111+
var rightHand = new TestHand(Handedness.Right);
109112
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
110-
Assert.IsTrue(myPointer.IsInteractionEnabled, $"Pointer {myPointer.PointerName} should be enabled, cube in front camera. Cube size {cube.transform.localScale} location {cube.transform.position}.");
111113

112-
// Make cube no longer visible
113-
cube.transform.Translate(Vector3.up * 10);
114-
yield return testHand.MoveTo(cube.transform.position);
114+
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
115+
cube.AddComponent<NearInteractionGrabbable>();
116+
cube.AddComponent<NearInteractionTouchableVolume>();
117+
yield return rightHand.Show(Vector3.zero);
115118
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
116-
Assert.IsFalse(myPointer.IsInteractionEnabled, $"Pointer {myPointer.PointerName} should NOT be enabled, cube behind camera. Cube size {cube.transform.localScale} location {cube.transform.position}.");
117119

118-
// For sphere and poke pointers, test that setting IgnoreCollidersNotInFOV works
119-
if (myPointer is SpherePointer spherePointer)
120-
{
121-
spherePointer.IgnoreCollidersNotInFOV = false;
122-
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
123-
Assert.IsTrue(myPointer.IsInteractionEnabled, $"Pointer {myPointer.PointerName} should be enabled because IgnoreCollidersNotInFOV is false.");
124-
spherePointer.IgnoreCollidersNotInFOV = true;
125-
}
126-
else if (myPointer is PokePointer pokePointer)
127-
{
128-
pokePointer.IgnoreCollidersNotInFOV = false;
129-
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
130-
Assert.IsTrue(myPointer.IsInteractionEnabled, $"Pointer {myPointer.PointerName} should be enabled because IgnoreCollidersNotInFOV is false.");
131-
pokePointer.IgnoreCollidersNotInFOV = true;
132-
}
120+
var spherePointer = PointerUtils.GetPointer<SpherePointer>(Handedness.Right);
121+
var pokePointer = PointerUtils.GetPointer<PokePointer>(Handedness.Right);
133122

134-
// Move it back to be visible again
135-
cube.transform.Translate(Vector3.up * -10f);
136-
yield return testHand.MoveTo(cube.transform.position);
137-
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
138-
Assert.IsTrue(myPointer.IsInteractionEnabled, $"Pointer {myPointer.PointerName} should be enabled because it is near object inside of FOV. Cube size {cube.transform.localScale} location {cube.transform.position}.");
123+
yield return TestPointerFOVLargeColliderHelper(spherePointer, cube, rightHand);
124+
yield return TestPointerFOVLargeColliderHelper(pokePointer, cube, rightHand);
125+
126+
rightHand.Hide();
127+
GameObject.Destroy(cube);
139128
}
140129

141130
/// <summary>
142131
/// Tests that pointers behave correctly when interacting with objects inside and outside
143132
/// its field of view
144133
/// </summary>
145134
[UnityTest]
146-
public IEnumerator TestPointerFieldOfView()
135+
public IEnumerator TestPointerFOV()
147136
{
148137
var rightHand = new TestHand(Handedness.Right);
149138
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
@@ -157,14 +146,13 @@ public IEnumerator TestPointerFieldOfView()
157146
var spherePointer = PointerUtils.GetPointer<SpherePointer>(Handedness.Right);
158147
var pokePointer = PointerUtils.GetPointer<PokePointer>(Handedness.Right);
159148

160-
yield return TestPointerFieldOfViewHelper(spherePointer, cube, rightHand);
161-
yield return TestPointerFieldOfViewHelper(pokePointer, cube, rightHand);
149+
yield return TestPointerFOVHelper(spherePointer, cube, rightHand);
150+
yield return TestPointerFOVHelper(pokePointer, cube, rightHand);
162151

163152
rightHand.Hide();
164153
GameObject.Destroy(cube);
165154
}
166155

167-
168156
/// <summary>
169157
/// Tests that sphere pointer grabs object when hand is inside a giant grabbable
170158
/// </summary>
@@ -369,6 +357,69 @@ private static T CreatePointerPrefab<T>(string prefabPath,
369357
return pointer;
370358
}
371359

360+
private IEnumerator TestPointerFOVHelper(IMixedRealityPointer myPointer, GameObject cube, TestHand testHand)
361+
{
362+
// Cube in front of camera
363+
cube.transform.SetPositionAndRotation(Vector3.forward * 1f, Quaternion.identity);
364+
cube.transform.localScale = Vector3.one * 0.1f;
365+
yield return testHand.MoveTo(cube.transform.position);
366+
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
367+
Assert.IsTrue(myPointer.IsInteractionEnabled, $"Pointer {myPointer.PointerName} should be enabled, cube in front camera. Cube size {cube.transform.localScale} location {cube.transform.position}.");
368+
369+
// Cube above camera
370+
cube.transform.Translate(Vector3.up * 10);
371+
yield return testHand.MoveTo(cube.transform.position);
372+
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
373+
Assert.IsFalse(myPointer.IsInteractionEnabled, $"Pointer {myPointer.PointerName} should NOT be enabled, cube behind camera. Cube size {cube.transform.localScale} location {cube.transform.position}.");
374+
375+
// For sphere and poke pointers, test that setting IgnoreCollidersNotInFOV works
376+
if (myPointer is SpherePointer spherePointer)
377+
{
378+
spherePointer.IgnoreCollidersNotInFOV = false;
379+
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
380+
Assert.IsTrue(myPointer.IsInteractionEnabled, $"Pointer {myPointer.PointerName} should be enabled because IgnoreCollidersNotInFOV is false.");
381+
spherePointer.IgnoreCollidersNotInFOV = true;
382+
}
383+
else if (myPointer is PokePointer pokePointer)
384+
{
385+
pokePointer.IgnoreCollidersNotInFOV = false;
386+
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
387+
Assert.IsTrue(myPointer.IsInteractionEnabled, $"Pointer {myPointer.PointerName} should be enabled because IgnoreCollidersNotInFOV is false.");
388+
pokePointer.IgnoreCollidersNotInFOV = true;
389+
}
390+
391+
// Move it back to be visible again
392+
cube.transform.Translate(Vector3.up * -10f);
393+
yield return testHand.MoveTo(cube.transform.position);
394+
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
395+
Assert.IsTrue(myPointer.IsInteractionEnabled, $"Pointer {myPointer.PointerName} should be enabled because it is near object inside of FOV. Cube size {cube.transform.localScale} location {cube.transform.position}.");
396+
}
397+
398+
private IEnumerator TestPointerFOVLargeColliderHelper(IMixedRealityPointer myPointer, GameObject cube, TestHand testHand)
399+
{
400+
cube.transform.localScale = new Vector3(3, 3, 0.05f);
401+
float[] yOffsets = new float[] { -1f, 0f, 1f };
402+
float[] xOffsets = new float[] { -1f, 0f, 1f };
403+
float[] zOffsets = new float[] { 1f, -1f };
404+
var collider = cube.GetComponent<BoxCollider>();
405+
foreach (var zOffset in zOffsets)
406+
{
407+
foreach (var yOffset in yOffsets)
408+
{
409+
foreach (var xOffset in xOffsets)
410+
{
411+
var cameraPos = CameraCache.Main.transform.position;
412+
var pos = new Vector3(cameraPos.x + xOffset, cameraPos.y + yOffset, cameraPos.z + zOffset);
413+
cube.transform.position = pos;
414+
yield return testHand.MoveTo(cube.transform.position);
415+
yield return PlayModeTestUtilities.WaitForInputSystemUpdate();
416+
bool isInFov = CameraCache.Main.IsInFOVCached(collider);
417+
Assert.IsTrue(zOffset == 1f ? myPointer.IsInteractionEnabled : !myPointer.IsInteractionEnabled,
418+
$"Pointer {myPointer.PointerName} in incorrect state. IsInFOV {isInFov} Cube size {cube.transform.localScale} location {cube.transform.position}.");
419+
}
420+
}
421+
}
422+
}
372423
#endregion
373424
}
374425
}

Assets/MixedRealityToolkit/Utilities/CameraFOVChecker.cs

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,79 +5,97 @@
55
using System;
66
using System.Collections.Generic;
77
using UnityEngine;
8+
using UnityEngine.Profiling;
89

910
namespace Microsoft.MixedReality.Toolkit
1011
{
1112
/// <summary>
12-
/// Camera extension methods to test if colliders are within an FOV cone. Uses
13+
/// Camera extension methods to test if colliders are within camera's FOV. Uses
1314
/// caching to improve performance and ensure values are only computed once per frame
1415
/// </summary>
1516
public static class CameraFOVChecker
1617
{
1718

1819
// Help to clear caches when new frame runs
19-
static private int inFOVConeLastCalculatedFrame = -1;
20+
static private int inFOVLastCalculatedFrame = -1;
2021
// Map from grabbable => is the grabbable in FOV for this frame. Cleared every frame
21-
private static Dictionary<Tuple<Collider, Camera>, bool> inFOVConeColliderCache = new Dictionary<Tuple<Collider, Camera>, bool>();
22+
private static Dictionary<Tuple<Collider, Camera>, bool> inFOVColliderCache = new Dictionary<Tuple<Collider, Camera>, bool>();
2223
// List of corners shared across all sphere pointer query instances --
2324
// used to store list of corners for a bounds. Shared and static
2425
// to avoid allocating memory each frame
25-
private static List<Vector3> inFOVConeBoundsCornerPoints = new List<Vector3>();
26+
private static List<Vector3> inFOVBoundsCornerPoints = new List<Vector3>();
2627

2728
/// <summary>
2829
/// Returns true if a collider's bounds is within the camera FOV.
2930
/// Utilizes a cache to test if this collider has been seen before and returns current frame's calculated result.
31+
/// NOTE: This is a 'loose' FOV check -- it can return true in cases when the collider is actually not in the FOV
32+
/// because it does an axis-aligned check when testing for large colliders. So, if the axis aligned bounds are in the bounds of the camera, it will return true.
3033
/// </summary>
3134
/// <param name="myCollider">The collider to test</param>
32-
public static bool IsInFOVConeCached(this Camera cam,
33-
Collider myCollider)
35+
public static bool IsInFOVCached(this Camera cam, Collider myCollider)
3436
{
3537
// if the collider's size is zero, it is not visible. Return false.
36-
if(myCollider.bounds.size == Vector3.zero || myCollider.transform.localScale == Vector3.zero)
38+
if (myCollider.bounds.size == Vector3.zero || myCollider.transform.localScale == Vector3.zero)
3739
{
3840
return false;
3941
}
40-
42+
4143
Tuple<Collider, Camera> cameraColliderPair = new Tuple<Collider, Camera>(myCollider, cam);
4244
bool result = false;
43-
if (inFOVConeLastCalculatedFrame != Time.frameCount)
45+
if (inFOVLastCalculatedFrame != Time.frameCount)
4446
{
45-
inFOVConeColliderCache.Clear();
46-
inFOVConeLastCalculatedFrame = Time.frameCount;
47+
inFOVColliderCache.Clear();
48+
inFOVLastCalculatedFrame = Time.frameCount;
4749
}
48-
else if (inFOVConeColliderCache.TryGetValue(cameraColliderPair, out result))
50+
else if (inFOVColliderCache.TryGetValue(cameraColliderPair, out result))
4951
{
5052
return result;
5153
}
5254

53-
inFOVConeBoundsCornerPoints.Clear();
54-
BoundsExtensions.GetColliderBoundsPoints(myCollider, inFOVConeBoundsCornerPoints, 0);
55+
inFOVBoundsCornerPoints.Clear();
56+
BoundsExtensions.GetColliderBoundsPoints(myCollider, inFOVBoundsCornerPoints, 0);
57+
5558

5659
float xMin = float.MaxValue, yMin = float.MaxValue, zMin = float.MaxValue;
5760
float xMax = float.MinValue, yMax = float.MinValue, zMax = float.MinValue;
58-
for (int i = 0; i < inFOVConeBoundsCornerPoints.Count; i++)
61+
for (int i = 0; i < inFOVBoundsCornerPoints.Count; i++)
5962
{
60-
var corner = inFOVConeBoundsCornerPoints[i];
61-
if (cam.IsInFOVCone(corner, 0))
63+
var corner = inFOVBoundsCornerPoints[i];
64+
Vector3 screenPoint = cam.WorldToViewportPoint(corner);
65+
66+
bool isInFOV = screenPoint.z >= 0 && screenPoint.z <= cam.farClipPlane
67+
&& screenPoint.x >= 0 && screenPoint.x <= 1
68+
&& screenPoint.y >= 0 && screenPoint.y <= 1;
69+
70+
if (isInFOV)
6271
{
63-
inFOVConeColliderCache.Add(cameraColliderPair, true);
72+
inFOVColliderCache.Add(cameraColliderPair, true);
6473
return true;
6574
}
6675

67-
xMin = Mathf.Min(xMin, corner.x);
68-
yMin = Mathf.Min(yMin, corner.y);
69-
zMin = Mathf.Min(zMin, corner.z);
70-
xMax = Mathf.Max(xMax, corner.x);
71-
yMax = Mathf.Max(yMax, corner.y);
72-
zMax = Mathf.Max(zMax, corner.z);
76+
// if the point is behind the camera, the x and y viewport positions are negated
77+
var zViewport = screenPoint.z;
78+
var xViewport = zViewport >= 0 ? screenPoint.x : -screenPoint.x;
79+
var yViewport = zViewport >= 0 ? screenPoint.y : -screenPoint.y;
80+
xMin = Mathf.Min(xMin, xViewport);
81+
yMin = Mathf.Min(yMin, yViewport);
82+
zMin = Mathf.Min(zMin, zViewport);
83+
xMax = Mathf.Max(xMax, xViewport);
84+
yMax = Mathf.Max(yMax, yViewport);
85+
zMax = Mathf.Max(zMax, zViewport);
7386
}
7487

75-
var cameraPos = cam.transform.position;
76-
result = xMin <= cameraPos.x && cameraPos.x <= xMax
77-
&& yMin <= cameraPos.y && cameraPos.y <= yMax
78-
&& zMin <= cameraPos.z && cameraPos.z <= zMax;
88+
// Check that collider is visible even if all corners are not visible
89+
// such as when having a large collider
90+
result =
91+
zMax > 0 // Front of collider is in front of the camera.
92+
&& zMin < cam.farClipPlane // Back of collider is not too far away.
93+
&& xMin < 1 // Left edge is not too far to the right.
94+
&& xMax > 0 // Right edge is not too far to the left.
95+
&& yMin < 1 // Bottom edge is not too high.
96+
&& yMax > 0; // Top edge is not too low.
7997

80-
inFOVConeColliderCache.Add(cameraColliderPair, result);
98+
inFOVColliderCache.Add(cameraColliderPair, result);
8199

82100
return result;
83101
}

0 commit comments

Comments
 (0)