判断物体是否在屏幕内
判断物体是否在屏幕内
isVisible
使用 Unity 的属性Renderer.isVisible
以及方法OnBecameVisible
和OnBecameInvisible
可以粗略地判断游戏对象是否可见。
当游戏对象对任一摄像机变成可见时,OnBecameVisible
就会被调用,同时Renderer.isVisible
会变成 true。
但是要注意对摄像机“可见”指该游戏对象需要参与渲染,因此即使对象本身不在场景内,但也可能会为了计算阴影而参与渲染。此外当在编辑器中运行时,对场景视图摄像机可见也会导致Renderer.isVisible
为 true。
using UnityEngine;
public class Example : MonoBehaviour
{
void OnBecameVisible()
{
Debug.Log("Object is visible");
}
void OnBecameInvisible()
{
Debug.Log("Object is no longer visible");
}
// 或者可以直接读取 Renderer 的 isVisible 属性
new Renderer renderer;
void Start()
{
renderer = GetComponent<Renderer>();
}
void Update()
{
if (renderer.isVisible)
{
Debug.Log("Object is visible");
}
else
{
Debug.Log("Object is no longer visible");
}
}
}
CalculateFrustumPlanes 和 TestPlanesAABB
Unity 的GeometryUtility
提供了两个实用方法:CalculateFrustumPlanes
和TestPlanesAABB
,前者可以获取摄像机的视锥体的六个平面,后者可以判断给定的包围盒(bounds)是否位于平面数组内或者与其中任何平面相交。
因此,对于有边界(renderer.bounds
或者collider.bounds
)的物体,可以使用这两个方法判断其是否在摄像机的可视范围内。
public bool IsVisibleFrom(Bounds bounds, Camera camera)
{
// 获取摄像机的视锥体的六个平面
Plane[] planes = GeometryUtility.CalculateFrustumPlanes(camera);
// 判断包围盒是否在平面数组内(或与任一平面相交)
return GeometryUtility.TestPlanesAABB(planes, bounds);
}
// 在大多数情况下,可以假定摄像机为主摄像机
public bool IsVisibleFrom(Bounds bounds)
{
IsVisibleFrom(bounds, Camera.main);
}
对于复杂的组合游戏对象,其包围盒的获取方法可以参考前面的文章。
限制物体在屏幕内移动(2D场景)
上面两种方法都可以判断游戏对象是否在屏幕内,但是只要对象有一部分在摄像机可视范围内就会认为是在屏幕内,无法区分是完全在屏幕内还是只有部分在屏幕内。
另外在某些游戏场景中(通常为2D类型),我们需要限制游戏对象只能在屏幕范围内移动,这时候就还需要知道屏幕外的对象距离屏幕有多远(以便将其移回到屏幕内)。
以CalculateFrustumPlanes
计算出的摄像机视锥体平面组为基础,我们接下来对TestPlanesAABB
进行改造。
注意
这里仅考虑以下场景:
- 摄像机为正投影摄像机
- 摄像机的旋转为 R:[0, 0, 0],即初始状态
- 包围盒比摄像机视锥体小,前者可以被后者完全包含
首先设计方法的功能,未经旋转的正投影摄像机的视锥体可以视为一个包围盒,通过构建该包围盒可以让我们在后续的处理中使用 Unity 的Bounds
类内置的方法,以快速判断点和视锥体的关系。
我们使用一个参数testType
来表示检测的类型,根据参数的不同,该方法的功能分别为:
- 检测包围盒的中心是否在视锥体内(或与某一平面相交)
- 检测包围盒是否完全在视锥体内
- 检测包围盒是否有任意一部分在视锥体内
除了返回 bool 类型的判断结果外,我们还需要一个返回值distance
接收将被检测包围盒的中心/整体/任意一部分平移到视锥体内需要移动的向量,当检测结果为 true 时,distance
为Vector3.zero
。
我们用一个枚举类来表示这三种检测类型:
enum TestType {
center, // 包围盒的中心是否在视锥体内
total, // 包围盒是否完全在视锥体内
partial // 包围盒是否有任意一部分在视锥体内
}
综上可以得到如下方法签名:
// 获取正投影摄像机的视锥体构成的包围盒
Bounds BoundsOfCamera(Camera cam);
// 判断包围盒是否在指定摄像机的视锥体内,并返回将包围盒平移到视锥体内的向量
bool TestCameraAABB(Camera cam, Bounds bounds, TestType testType, out Vector3 distance);
我们先来实现第一个方法。AABB包围盒可以通过最小点min
和最大点max
定义,对于正投影摄像机的视锥体构成的包围盒,其最小点就是屏幕左下角在近剪切面的点,而最大点是屏幕右上角在远剪切面的点。代码如下:
using UnityEngine;
using UnityEngine.Assertions;
public class Utils : MonoBehaviour
{
// 获取正投影摄像机的视锥体构成的包围盒
public static Bounds BoundsOfCamera(Camera cam)
{
// 该方法仅适用于未经旋转的正投影摄像机
Assert.IsTrue(cam.orthographic && cam.transform.eulerAngles == Vector3.zero);
// 构建表示屏幕左下角和右上角的坐标
Vector3 bottomLeft = Vector3.zero;
Vector3 topRight = new Vector3(Screen.width, Screen.height, 0);
// 将两个坐标转化为世界坐标
Vector3 boundBLN = cam.ScreenToWorldPoint(bottomLeft);
Vector3 boundTRN = cam.ScreenToWorldPoint(topRight);
// 将转换后的三维坐标的 z 分量分别设置为摄像机的近剪切面和远剪切面的 z 坐标
boundBLN.z += cam.nearClipPlane;
boundTRN.z += cam.farClipPlane;
// 根据以上两个点就可以确定一个包围盒
Bounds bounds = new Bounds(Vector3.zero, Vector3.zero);
bounds.setMinMax(boundBLN, boundTRN);
return bounds;
}
}
然后是第二个方法。由于我们可以通过方法一将摄像机视锥体转换为包围盒,所以只需要研究怎么判断一个包围盒与另一个包围盒的关系。因为这里只考虑包围盒比摄像机视锥体小的情况,我们把游戏对象的包围盒标记为lilB
,摄像机视锥体转换的包围盒标记为bigB
(该情况可以用数学描述为包围盒lilB
的size
在x、y、z任一坐标轴上的分量都比包围盒bigB
的对应值小)。
要判断lilB
的中心是否在bigB
中以及计算它们的距离都很简单,Unity 的 Bounds 类提供了判断点是否在包围盒内的方法Bounds.Contains
以及获取包围盒中离给定点最近的点的方法Bounds.ClosetPoint
。
要判断lilB
是否完全被包含在bigB
中则要考虑以下结论:如果lilB
的最小点和最大点均在bigB
内,则lilB
完全被bigB
包含;否则lilB
的最小点到bigB
的距离和lilB
的最大点到bigB
的距离的较大值即为将lilB
整体平移到bigB
内需要移动的距离。
而要判断lilB
是否有任意一部分被包含在bigB
中则要考虑以下类似的结论:如果lilB
的最小点或最大点在bigB
内,则lilB
有任意一部分被bigB
包含;否则lilB
的最小点到bigB
的距离和lilB
的最大点到bigB
的距离的较小值即为将lilB
的任意一部分平移到bigB
内需要移动的距离。
因此方法TestCameraAABB
的具体实现如下:
using UnityEngine;
using UnityEngine.Assertions;
public class Utils : MonoBehaviour
{
// 获取正投影摄像机的视锥体构成的包围盒
public static Bounds BoundsOfCamera(Camera cam)
{ ... }
// 由于摄像机是相对固定的,应该将视锥体包围盒保存起来,避免每次判断时都要重新计算包围盒的消耗
private static Bounds _camBounds;
public static Bounds camBounds
{
get {
// Bounds 是一个值类型,所以其默认值不是 null,而是 center: (0,0,0), size: (0,0,0)
if(_camBounds.size == Vector3.zero)
{
SetCamBounds(Camera.main);
}
return _camBounds;
}
}
public static void SetCamBounds(Camera cam)
{
_camBounds = BoundsOfCamera(Camera.main);
}
// 判断包围盒是否在指定摄像机的视锥体内,并返回将包围盒平移到视锥体内的向量
// 如果要频繁地调用,应该使用 TestCameraAABB(Bounds bounds, TestType testType, out Vector3 distance) 代替
public static bool TestCameraAABB(Camera cam, Bounds bounds, TestType testType, out Vector3 distance)
{
return BoundsInBoundsCheck(BoundsOfCamera(cam), bounds, testType, out distance);
}
// 判断包围盒是否在摄像机的视锥体内,并返回将包围盒平移到视锥体内的向量
// 可以提前通过 SetCamBounds 指定摄像机,如果不指定,默认使用主摄像机
public static bool TestCameraAABB(Bounds bounds, TestType testType, out Vector3 distance)
{
return BoundsInBoundsCheck(camBounds, bounds, testType, out distance);
}
// 检查小包围盒 lilB 是否在大包围和 bigB 内,并返回将 lilB 平移到 bigB 内需要移动的向量
static bool BoundsInBoundsCheck(Bounds bigB, Bounds lilB, TestType testType, out Vector3 distance)
{
Assert.IsTrue(lilB.size.x < bigB.size.x && lilB.size.y < bigB.size.y && lilB.size.z < bigB.size.z);
// 初始化 distance
distance = Vector3.zero;
switch (testType)
{
// 判断 lilB 的中心是否在 bigB 内
case TestType.center:
Vector3 centerPoint = lilB.center;
if (bigB.Contains(centerPoint))
{
return true;
}
distance = bigB.ClosetPoint(centerPoint) - centerPoint;
return false;
// 判断 lilB 是否完全在 bigB 内
case TestType.total:
if (bigB.Contains(lilB.min) && bigB.Contains(lilB.max))
{
return true;
}
Vector3 closetToMin = bigB.ClosetPoint(centerPoint) - lilB.min;
Vector3 closetToMax = bigB.ClosetPoint(centerPoint) - lilB.max;
distance = closetToMin.magnitude > closetToMax.magnitude ? closetToMin : closetToMax;
return false;
// 判断 lilB 是否有任意一部分在 bigB 内
case TestType.partial:
if (bigB.Contains(lilB.min) || bigB.Contains(lilB.max))
{
return true;
}
Vector3 closetToMin = bigB.ClosetPoint(centerPoint) - lilB.min;
Vector3 closetToMax = bigB.ClosetPoint(centerPoint) - lilB.max;
distance = closetToMin.magnitude > closetToMax.magnitude ? closetToMax : closetToMin;
return false;
default:
throw new ArgumentOutOfRangeException($"Unknow TestType: {testType}");
}
}
}
现在,我们就可以利用上面的方法将物体限制在屏幕内移动了。
public class Moveable : MonoBehaviour
{
// 游戏对象的包围盒
public Bounds bounds;
...
void Update()
{
float xAxis = Input.GetAxis("Horizontal");
float zAxis = Input.GetAxis("Vertical");
Vector3 pos = transform.position;
pos.x += xAxis * speed * Time.deltaTime;
pos.z += zAxis * speed * Time.deltaTime;
// 先移动包围盒的位置
Bounds.center = pos;
// 使游戏对象保持在屏幕内
Vector3 off;
if (!Utils.TestCameraAABB(bounds, TestType.total, off))
{
pos += off;
Bounds.center = pos;
}
transform.position = pos;
}
}