Unity 2D 自定义碰撞系统(一)

很久之前就想要用Unity实现一个比较复古的碰撞效果。
但是由于Unity的刚体是基于物理运算的,在发生碰撞的时候,会出现反弹等我们不希望出现的效果。
所以通过查看了一些类似的插件和官方的一些项目作为参考,实现了一个没有力的概念的碰撞系统。

效果


可以看出手感已经很平滑了,而且对于较为边缘的碰撞,也会自动向外部偏移,这个主要利用了切线的方向,后面会详细提到。
这一节主要讨论碰撞的算法实现,后续可能会更新一套完整的自定义物理系统的架构,可以扩展出许多好玩的效果比如:



推箱子

减速带

减速区

自定义碰撞算法

1.核心函数

Rigidbody2D.Cast(Vector2 direction, ContactFilter2D contactFilter, List<RaycastHit2D> results, float distance = Mathf.Infinity);

该函数会预先检测移动范围内所有的碰撞体。
该函数并不会导致物体移动。
物体移动使用Rigidbody2D.position。

参数和返回值

该函数返回值为int类型,表示检测到的碰撞物体的数量。
direction:表示运动方向。
contactFilter:表示碰撞设置,是一个struct,可以设置碰撞的layer,是否忽略trigger等。
results:返回发生碰撞的信息,也是一个struct,包括碰撞点,碰撞法线,碰撞点到物体的距离等信息。
distance:移动的距离。

Collider2D也拥有Cast函数,与Rigidbody2D的Cast方法的参数和返回值是相同的,不过两个函数也有不同点。

Rigidbody2D.Cast应该在FixedUpdate中调用,同时最终的移动采用Rigidbody.position进行移动。
Collider2D.Cast应该在Update中调用,最终的移动采用transform.Translate进行移动。

Rigidbody2D.Cast会检测该物体下所有碰撞体集合的信息。
Collider2D.Cast只会检测当前碰撞体的碰撞信息。

2.初始化

[RequireComponent(typeof(Collider2D))]
public class PhysicalObject : MonoBehaviour
{
	private const float MIN_MOVE_DISTANCE = 0.001f;

    private new Rigidbody2D rigidbody2D;
    private ContactFilter2D contactFilter2D;
    private readonly List<RaycastHit2D> raycastHit2DList = new List<RaycastHit2D>();

    public LayerMask layerMask;
    public Vector2 velocity;


    void Start()
    {
        rigidbody2D = GetComponent<Rigidbody2D>();

        if (rigidbody2D == null)
            rigidbody2D = gameObject.AddComponent<Rigidbody2D>();

        rigidbody2D.hideFlags = HideFlags.NotEditable;
        rigidbody2D.bodyType = RigidbodyType2D.Kinematic;
        rigidbody2D.simulated = true;
        rigidbody2D.useFullKinematicContacts = false;
        rigidbody2D.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
        rigidbody2D.sleepMode = RigidbodySleepMode2D.NeverSleep;
        rigidbody2D.interpolation = RigidbodyInterpolation2D.Interpolate;
        rigidbody2D.constraints = RigidbodyConstraints2D.FreezeRotation;
        rigidbody2D.gravityScale = 0;

        contactFilter2D = new ContactFilter2D
        {
            useLayerMask = true,
            useTriggers = false,
            layerMask = layerMask
        };
    }

    private void OnValidate()
    {
        contactFilter2D.layerMask = layerMask;
    }
}

这里我们将Rigidbody2D的bodyType设置为Kinematic类型,并且useFullKinematicContacts为false。
Kinematic类型的刚体会返回碰撞信息,但是并不会对刚体造成物理影响,这正是我们需要的。
useFullKinematicContacts之所以设置为false,是因为我们采用Cast的方式进行碰撞检测而不是采用OnCollisionXX函数,所以为了减少不必要的性能消耗。

3.碰撞检测

private void Update()
{
    velocity = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"));
}

private void FixedUpdate()
{
    Movement(velocity * Time.deltaTime * 5f);
}

private void Movement(Vector2 deltaPosition)
{
	if (deltaPosition == Vector2.zero)
	          return;
	          
	Vector2 updateDeltaPosition = Vector2.zero;
	
	float distance = deltaPosition.magnitude;
	Vector2 direction = deltaPosition.normalized;
	
	if (distance <= MIN_MOVE_DISTANCE)
	    distance = MIN_MOVE_DISTANCE;
	
	rigidbody2D.Cast(direction, contactFilter2D, raycastHit2DList, distance);
	
	Vector2 finalDirection = direction;
	float finalDistance = distance;
	
	foreach (var hit in raycastHit2DList)
	{
	    //DoSth
	}
	
	updateDeltaPosition += finalDirection * finalDistance;
	
}

我们在FixedUpdate中调用Movement方法,并且检测所有的碰撞信息。
在执行完Cast方法后,该方法会将所有的碰撞信息保存在raycastHit2DList中。
在Unity5.6版本之前,使用Foreach遍历每一帧都会GC,所以5.6之前对List的遍历需要使用For循环或者枚举器。(详细:https://www.jianshu.com/p/03760933e2fa)
个人测试Foreach的效率比For要好,大概2:2.5的样子,所以这里用的Foreach,测试版本Unity2018.3和2019.1。

接下来就是重头戏了,碰撞检测的方法。
不过在那之前,我们先明确一些东西。
首先传入Cast方法的Direction和Distance是固定的,这就意味着这次检测的方向是固定的,同时也就说明,最终移动的方向一定是检测的方向,否则这次检测将失去意义。
所以对于最终移动的方向和距离,我们只需要关心它的距离就好了。
并且最终的距离一定会小于Distance。

foreach (var hit in raycastHit2DList)
{
     float moveDistance = hit.distance;

     if (moveDistance < finalDistance)
     {
         finalDistance = moveDistance;
     }
 }

在距离上,我们遍历所有的碰撞信息,并且找出最短的那个,这说明这个物体是距离我们最近的,同时也是我们应该停止的地方。
Unity 2D 自定义碰撞系统(一)_第1张图片
但是当我们发生碰撞的时候,无论什么方向,都无法进行移动了。
Debug一下

foreach (var hit in raycastHit2DList)
{
     float moveDistance = hit.distance;

     Debug.DrawLine(hit.point, hit.point + hit.normal, Color.white);
     Debug.DrawLine(hit.point, hit.point + direction, Color.yellow);

     Debug.Log(hit.distance);

     if (moveDistance < finalDistance)
     {
         finalDistance = moveDistance;
     }
 }

Unity 2D 自定义碰撞系统(一)_第2张图片
在这里插入图片描述
黄色线代表移动方向,白色线代表碰撞法线的方向。
这时候我们发现无论我们朝着哪个方向进行移动,碰撞的法线方向始终是固定的,并且发生碰撞时,两物体之间的距离始终是0。
所以我们得出的结论就是,碰撞法线和距离是固定,与我们要移动的方向无关。
既然碰撞法线和距离是一定的,也就是说,无论我们朝着什么方向移动,他都不会改变,所以此时我们需要判断移动的方向与碰撞法线的方向,来决定该如何移动。

foreach (var hit in raycastHit2DList)
{
     float moveDistance = hit.distance;

     Debug.DrawLine(hit.point, hit.point + hit.normal, Color.white);
     Debug.DrawLine(hit.point, hit.point + direction, Color.yellow);

     Debug.Log(hit.distance);
     
     float projection = Vector2.Dot(hit.normal, direction);

     if (projection >= 0)
     {
         moveDistance = distance;
     }
	 
     if (moveDistance < finalDistance)
     {
         finalDistance = moveDistance;
     }
 }

Unity 2D 自定义碰撞系统(一)_第3张图片
此时,我们加入一个点乘判断,这个判断表示,如果我们想要移动的方向与碰撞法线的方向基本相反(projection < 0),moveDistance = hit.distance,也就是0,表示无法移动。
如果想要移动的方向与碰撞法线方向呈90度(projection == 0)或者基本相同(projection > 0)说明我们要移动的方向并不会收到碰撞的限制,所以此时将moveDistance = distance,也就是初始运动的距离。

但是还有一个问题就是,虽然我们能够正确的进行移动了,但是当我们靠近物体的时候,只有在两方向夹角小于等于90度的时候才能够进行移动,如果我希望即使靠近物体,仍旧能够取得移动距离在可移动方向的分量怎么办?
可能有点绕,直白一点可以表示为,贴着墙摩擦前进。

于是我们需要引入切线来达到这个目的。
那么如何获取正确的需要移动的切线方向呢?
Unity 2D 自定义碰撞系统(一)_第4张图片
首先我们需要明确的是,切线方向是碰撞法线的切线方向,而不是移动方向的
其次切线的方向要保持和移动方向基本相同,因为是移动方向的分量。
(切线用品红色表示)

foreach (var hit in raycastHit2DList)
{
    float moveDistance = hit.distance;

    Debug.DrawLine(hit.point, hit.point + hit.normal, Color.white);
    Debug.DrawLine(hit.point, hit.point + direction, Color.yellow);

    float projection = Vector2.Dot(hit.normal, direction);

    if (projection >= 0)
    {
        moveDistance = distance;
    }
    else
    {
        Vector2 tangentDirection = new Vector2(hit.normal.y, -hit.normal.x);

        float tangentDot = Vector2.Dot(tangentDirection, direction);

        if (tangentDot < 0)
        {
            tangentDirection = -tangentDirection;
            tangentDot = -tangentDot;
        }

        float tangentDistance = tangentDot * distance;

        Debug.DrawLine(hit.point, hit.point + tangentDirection, Color.magenta);
    }

    if (moveDistance < finalDistance)
    {
        finalDistance = moveDistance;
    }
}

所以我们首先通过法线方向获取切线的方向,然后判断切线方向与移动方向点积,来确定切线的方向(实际上这里用切线表述并不准确,但是为了简单明了还是采用切线来表述,实际表述应该是移动方向在切线方向的分量)
并且确定移动距离在切线方向的分量,也就是切线方向需要移动的距离。

在获得这些信息之后,我们还需要做一次Cast检测,来确定切线方向的移动会不会碰撞到物体

//Class
private readonly List<RaycastHit2D> tangentRaycastHit2DList = new List<RaycastHit2D>();

{
	//Add
	if (tangentDot != 0)
	{
	    rigidbody2D.Cast(tangentDirection, contactFilter2D, tangentRaycastHit2DList, tangentDistance);
	
	    foreach (var tangentHit in tangentRaycastHit2DList)
	    {
	        Debug.DrawLine(tangentHit.point, tangentHit.point + tangentDirection, Color.magenta);
	
	        if (Vector2.Dot(tangentHit.normal, tangentDirection) >= 0)
	            continue;
	
	        if (tangentHit.distance < tangentDistance)
	            tangentDistance = tangentHit.distance;
	    }
	
	    updateDeltaPosition += tangentDirection * tangentDistance;
	}
}

首先我们需要在Class中声明一个盛放切线碰撞信息的容器。
然后进行切线方向的碰撞检测。
同样切线方向的移动也需要判断与法线的点积来确定如何移动。
Unity 2D 自定义碰撞系统(一)_第5张图片
最终效果。
如果想要做边缘自动偏移的话,将BoxCollider2D的Edge Radius设置一下就好了,这样正方形会变成圆角的正方形。

至此,关于碰撞检测算法已经基本完成了。
在此之前,我曾经尝试过很多种实现方式,但是最后采用这种方式,并且这种方式足够优雅~
只用了为数不多的代码。
并且实际上很多问题已经解决掉了,所以对这些东西感兴趣的朋友也可以自己尝试一下,这里也是提供一个参考。

完整代码:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(Collider2D))]
public class PhysicalObject : MonoBehaviour
{
    private const float MIN_MOVE_DISTANCE = 0.001f;

    private new Collider2D collider2D;
    private new Rigidbody2D rigidbody2D;
    private ContactFilter2D contactFilter2D;
    private readonly List<RaycastHit2D> raycastHit2DList = new List<RaycastHit2D>();
    private readonly List<RaycastHit2D> tangentRaycastHit2DList = new List<RaycastHit2D>();

    public LayerMask layerMask;
    [HideInInspector]
    public Vector2 velocity;


    void Start()
    {
        collider2D = GetComponent<Collider2D>();
        rigidbody2D = GetComponent<Rigidbody2D>();

        if (rigidbody2D == null)
            rigidbody2D = gameObject.AddComponent<Rigidbody2D>();

        rigidbody2D.hideFlags = HideFlags.NotEditable;
        rigidbody2D.bodyType = RigidbodyType2D.Kinematic;
        rigidbody2D.simulated = true;
        rigidbody2D.useFullKinematicContacts = false;
        rigidbody2D.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
        rigidbody2D.sleepMode = RigidbodySleepMode2D.NeverSleep;
        rigidbody2D.interpolation = RigidbodyInterpolation2D.Interpolate;
        rigidbody2D.constraints = RigidbodyConstraints2D.FreezeRotation;
        rigidbody2D.gravityScale = 0;

        contactFilter2D = new ContactFilter2D
        {
            useLayerMask = true,
            useTriggers = false,
            layerMask = layerMask
        };
    }

    private void OnValidate()
    {
        contactFilter2D.layerMask = layerMask;
    }

	private void Update()
    {
        velocity = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"));
    }

    private void FixedUpdate()
    {
        Movement(velocity * Time.deltaTime * 5f);
    }

    private void Movement(Vector2 deltaPosition)
    {
        if (deltaPosition == Vector2.zero)
            return;

        Vector2 updateDeltaPosition = Vector2.zero;

        float distance = deltaPosition.magnitude;
        Vector2 direction = deltaPosition.normalized;

        if (distance <= MIN_MOVE_DISTANCE)
            distance = MIN_MOVE_DISTANCE;

        rigidbody2D.Cast(direction, contactFilter2D, raycastHit2DList, distance);

        Vector2 finalDirection = direction;
        float finalDistance = distance;

        foreach (var hit in raycastHit2DList)
        {
            float moveDistance = hit.distance;

            Debug.DrawLine(hit.point, hit.point + hit.normal, Color.white);
            Debug.DrawLine(hit.point, hit.point + direction, Color.yellow);

            float projection = Vector2.Dot(hit.normal, direction);

            if (projection >= 0)
            {
                moveDistance = distance;
            }
            else
            {
                Vector2 tangentDirection = new Vector2(hit.normal.y, -hit.normal.x);

                float tangentDot = Vector2.Dot(tangentDirection, direction);

                if (tangentDot < 0)
                {
                    tangentDirection = -tangentDirection;
                    tangentDot = -tangentDot;
                }

                float tangentDistance = tangentDot * distance;

                if (tangentDot != 0)
                {
                    rigidbody2D.Cast(tangentDirection, contactFilter2D, tangentRaycastHit2DList, tangentDistance);

                    foreach (var tangentHit in tangentRaycastHit2DList)
                    {
                        Debug.DrawLine(tangentHit.point, tangentHit.point + tangentDirection, Color.magenta);

                        if (Vector2.Dot(tangentHit.normal, tangentDirection) >= 0)
                            continue;

                        if (tangentHit.distance < tangentDistance)
                            tangentDistance = tangentHit.distance;
                    }

                    updateDeltaPosition += tangentDirection * tangentDistance;
                }
            }

            if (moveDistance < finalDistance)
            {
                finalDistance = moveDistance;
            }
        }

        updateDeltaPosition += finalDirection * finalDistance;
        rigidbody2D.position += updateDeltaPosition;
    }
}

你可能感兴趣的:(Unity,2D,自定义碰撞系统)