I’ve searched and seen this question asked and answered at least a dozen times, but every single answer seems to make unreasonable simplifying assumptions about how the petitioner is making their game, and typically totally disregard a lot of edge cases which can not be ignored if you want to make a functional game. So, for all future internet searchers trying to find the answer to this question: here is code that will actually create a frustum from a screen-space Rect by determining the frustum corners in world space and populating a set of world-space planes.
public static class CameraExtensionsRts
{
const Camera.MonoOrStereoscopicEye k_CameraMode = Camera.MonoOrStereoscopicEye.Mono;
public static void CalculateSubFrustum(this Camera camera, Rect viewport, Plane[] frustumPlanes,
Camera.MonoOrStereoscopicEye cameraMode = k_CameraMode)
=> camera.CalculateSubFrustum(viewport, frustumPlanes, camera.nearClipPlane, camera.farClipPlane, cameraMode);
public static void CalculateSubFrustum(this Camera camera,
Rect screen, Plane[] frustumPlanes, float near, float far,
Camera.MonoOrStereoscopicEye cameraMode = k_CameraMode)
{
// NOTE: We're assuming the Rect passed in was read directly from mouse (pixel) coordinates, whereas the Rect
// CalculateFrustumCorners uses below takes viewport (normalized to [0-1]) coordinates
var min = (Vector2)camera.ScreenToViewportPoint(screen.min);
var max = (Vector2)camera.ScreenToViewportPoint(screen.max);
var viewport = Rect.MinMaxRect(min.x, min.y, max.x, max.y);
var nearCornersLocalSpace = new Vector3[4];
var farCornersLocalSpace = new Vector3[4];
// NOTE: This isn't in the documentation anywhere but it looks like output is in the camera's local space
// and always starts with the bottom left corner and winds clockwise
camera.CalculateFrustumCorners(viewport, near, cameraMode, nearCornersLocalSpace);
camera.CalculateFrustumCorners(viewport, far, cameraMode, farCornersLocalSpace);
// Transform local coordinates to world and concatenate into single array, ordered as follows
// indices 0-3 => near plane || indices 4-7 => far plane
// 0, 1, 2, 3 => bottom left, top left, top right, and bottom right, respectively, looking out from camera
// 4, 5, 6, 7 keep the same respective ordering
var frustumCornersIn = nearCornersLocalSpace
.Select(camera.transform.TransformPoint)
.Concat(farCornersLocalSpace
.Select(camera.transform.TransformPoint))
.ToArray();
// Populate the frustum planes according to the CalculateFrustumPlanes API
// (which for some reason doesn't take a viewport argument)
// https://docs.unity3d.com/ScriptReference/GeometryUtility.CalculateFrustumPlanes.html
// Left Plane
frustumPlanes[0].Set3Points(frustumCornersIn[0], frustumCornersIn[1], frustumCornersIn[5]);
// Right Plane
frustumPlanes[1].Set3Points(frustumCornersIn[2], frustumCornersIn[3], frustumCornersIn[7]);
// Bottom Plane
frustumPlanes[2].Set3Points(frustumCornersIn[0], frustumCornersIn[4], frustumCornersIn[7]);
// Top Plane
frustumPlanes[3].Set3Points(frustumCornersIn[1], frustumCornersIn[2], frustumCornersIn[6]);
// Near Plane
frustumPlanes[4].Set3Points(frustumCornersIn[2], frustumCornersIn[1], frustumCornersIn[0]);
// Far Plane
frustumPlanes[5].Set3Points(frustumCornersIn[4], frustumCornersIn[5], frustumCornersIn[6]);
}
}
Then, inside your input handling class you would need something like this.
public class YourSelectionManager : MonoBehaviour
{
bool m_IsBoxSelecting;
// NOTE: Might want to use Renderer visibility events to keep this list short
// https://docs.unity3d.com/ScriptReference/Renderer.OnBecameVisible.html
List<GameObject> m_SelectableUnits;
// Start this coroutine when a click-n-drag starts -- pass it the currently active Camera
IEnumerator DoBoxSelect(Camera camera)
{
m_IsBoxSelecting = true;
var start = GetPointerPositionScreenSpace();
// NOTE: You'll need to set m_IsBoxSelecting to false from whatever is monitoring the user input
yield return new WaitUntil(() => !m_IsBoxSelecting);
var end = GetPointerPositionScreenSpace();
var min = Vector2.Min(start, end);
var max = Vector2.Max(start, end);
var box = Rect.MinMaxRect(min.x, min.y, max.x, max.y);
// Box was not dragged out - just select the unit under the cursor
if (box.size == Vector2.zero)
DoClickSelect();
// If sides are too small, we'll create infinite planes when calculating the frustum, so clamp up a bit
const float sideLengthMin = 1e-2f;
if (box.width < sideLengthMin)
box.width = sideLengthMin;
if (box.height < sideLengthMin)
box.height = sideLengthMin;
var frustumPlanes = new Plane[6];
camera.CalculateSubFrustum(box, frustumPlanes);
foreach (var unit in m_SelectableUnits)
{
// Get whatever bounds are relevant to this unit - could be Renderer or Collider Bounds, just
// do NOT use MeshFilter Bounds (those are in local space)
var boundingBoxes = GetBounds(unit);
// If you are always using one Bounds per unit, this can be simplified by
// not returning/iterating on a List
if (boundingBoxes.Any(bounds => GeometryUtility.TestPlanesAABB(frustumPlanes, bounds)))
AddToSelection(unit);
}
}
// TODO: You implement these...
Vector2 GetPointerPositionScreenSpace() => throw new System.NotImplementedException();
void DoClickSelect() => throw new System.NotImplementedException();
void AddToSelection(GameObject selected) => throw new System.NotImplementedException();
List<Bounds> GetBounds(GameObject unit) => throw new System.NotImplementedException();
}
This will take the box a player draws in screen space and turn it into a world-space frustum that you can test your Bounds against using a built-in Unity API. A few important things to note:
- This frustum doesn’t do occlusion checking by itself, it will return positive if you pass it a bounds that is within the frustum even if those bounds are not visible because they’re behind something
- This ONLY works for cameras rendering Perspective, not Othographic. There are plenty of solutions to be found for Orthographic cameras as you’re safe to simply extend a big rectangle out of the camera into your scene.
- World-space Axis-Aligned Bounding Boxes (AABB) often extend outside the visual boundaries of your object, meaning that without additional constraints you will sometimes select objects when the box-select overlaps the bounding box, but not necessarily the rendered object. If you are using collider bounds and your colliders are well-constrained this shouldn’t be much of an issue.