很久之前就想要用Unity实现一个比较复古的碰撞效果。
但是由于Unity的刚体是基于物理运算的,在发生碰撞的时候,会出现反弹等我们不希望出现的效果。
所以通过查看了一些类似的插件和官方的一些项目作为参考,实现了一个没有力的概念的碰撞系统。
可以看出手感已经很平滑了,而且对于较为边缘的碰撞,也会自动向外部偏移,这个主要利用了切线的方向,后面会详细提到。
这一节主要讨论碰撞的算法实现,后续可能会更新一套完整的自定义物理系统的架构,可以扩展出许多好玩的效果比如:
冰
推箱子
减速带
减速区
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只会检测当前碰撞体的碰撞信息。
[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函数,所以为了减少不必要的性能消耗。
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;
}
}
在距离上,我们遍历所有的碰撞信息,并且找出最短的那个,这说明这个物体是距离我们最近的,同时也是我们应该停止的地方。
但是当我们发生碰撞的时候,无论什么方向,都无法进行移动了。
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;
}
}
黄色线代表移动方向,白色线代表碰撞法线的方向。
这时候我们发现无论我们朝着哪个方向进行移动,碰撞的法线方向始终是固定的,并且发生碰撞时,两物体之间的距离始终是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;
}
}
此时,我们加入一个点乘判断,这个判断表示,如果我们想要移动的方向与碰撞法线的方向基本相反(projection < 0),moveDistance = hit.distance,也就是0,表示无法移动。
如果想要移动的方向与碰撞法线方向呈90度(projection == 0)或者基本相同(projection > 0)说明我们要移动的方向并不会收到碰撞的限制,所以此时将moveDistance = distance,也就是初始运动的距离。
但是还有一个问题就是,虽然我们能够正确的进行移动了,但是当我们靠近物体的时候,只有在两方向夹角小于等于90度的时候才能够进行移动,如果我希望即使靠近物体,仍旧能够取得移动距离在可移动方向的分量怎么办?
可能有点绕,直白一点可以表示为,贴着墙摩擦前进。
于是我们需要引入切线来达到这个目的。
那么如何获取正确的需要移动的切线方向呢?
首先我们需要明确的是,切线方向是碰撞法线的切线方向,而不是移动方向的。
其次切线的方向要保持和移动方向基本相同,因为是移动方向的分量。
(切线用品红色表示)
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中声明一个盛放切线碰撞信息的容器。
然后进行切线方向的碰撞检测。
同样切线方向的移动也需要判断与法线的点积来确定如何移动。
最终效果。
如果想要做边缘自动偏移的话,将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;
}
}