判断物体是否在屏幕内

冬天吃雪糕2022年9月13日
大约 8 分钟

判断物体是否在屏幕内

isVisible

使用 Unity 的属性Renderer.isVisible以及方法OnBecameVisibleOnBecameInvisible可以粗略地判断游戏对象是否可见。

当游戏对象对任一摄像机变成可见时,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提供了两个实用方法:CalculateFrustumPlanesTestPlanesAABB,前者可以获取摄像机的视锥体的六个平面,后者可以判断给定的包围盒(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进行改造。

注意

这里仅考虑以下场景:

  1. 摄像机为正投影摄像机
  2. 摄像机的旋转为 R:[0, 0, 0],即初始状态
  3. 包围盒比摄像机视锥体小,前者可以被后者完全包含

首先设计方法的功能,未经旋转的正投影摄像机的视锥体可以视为一个包围盒,通过构建该包围盒可以让我们在后续的处理中使用 Unity 的Bounds类内置的方法,以快速判断点和视锥体的关系。

我们使用一个参数testType来表示检测的类型,根据参数的不同,该方法的功能分别为:

  • 检测包围盒的中心是否在视锥体内(或与某一平面相交)
  • 检测包围盒是否完全在视锥体内
  • 检测包围盒是否有任意一部分在视锥体内

除了返回 bool 类型的判断结果外,我们还需要一个返回值distance接收将被检测包围盒的中心/整体/任意一部分平移到视锥体内需要移动的向量,当检测结果为 true 时,distanceVector3.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(该情况可以用数学描述为包围盒lilBsize在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;
    }
}
上次编辑于: 2022/9/16 08:09:25
贡献者: WingSnow