前言
说到unity的物理系统,大家肯定第一反应肯定是“不就是rigidbody和collider那些东西吗,我会”。但是提及背后的原理,我敢说99%的人是不知道的。unity的物理系统很强大没错,然而当它不能满足我们的需求时,我们就需要自己写一套物理系统了。今天这个系列文章分享的是如何不依赖unity的api自己搭建一个简单的2D物理系统。 说是物理系统,其实只有最基础的部分,如下是演示之一:
(蓝色方块会不断移动,检测到碰撞进入时会变红色,检测到碰撞退出时会变蓝色)
这里面包含了这么几个功能:
- 刚体的移动(对应unity的rigidbody类)
- 碰撞检测的判定(对应unity的Physics类)
- 碰撞检测判定成功后的事件派发(对应unity的OnCollisionEnter和OnCollisionExit)
为了简化问题,规定了以下条件:
- 只有AABB(轴对称包围盒)
- 刚体在运动过程中Collider的大小不会发生变化
类功能说明
第一篇文章要实现的功能如下,即一个刚体受重力影响撞到碰撞体,然后停止。对应工程里的Test/Scenes/CollisionEvent场景。
这里面按照逻辑可以分为三步:
-
刚体受重力影响往下移动
-
刚体知道自己碰到了物体
-
刚体把自己的速度设成0
先来看UML图:
-
JCollisionController,碰撞检测基类,包含一个BoxColiider2D。(因为偷懒,工程里直接使用的是unity的BoxCollider2D,自己实现的话难度不大,注意当transform旋转和变化scale时,bounds的大小是会实时发生变化的)
-
JPlatform,平台类,会受到碰撞,不会主动发起碰撞,用于地面或墙壁等静止不动的物体。
-
JRigidbody,刚体类,会受重力影响,可以设置速度进行移动,会主动发起碰撞,用于玩家、敌人、子弹等各种会动的物体。
-
CollisionInfo,记录刚体碰撞信息,会用于游戏逻辑中,比如说要实现一个刚体碰撞到墙壁改变方向的功能就会用到这个信息。
-
RaycastOrigins,记录射线检测的起点。具体后面会讲。
-
JPhysicsManger,管理所有平台和刚体类,收集和处理碰撞信息,发送碰撞事件。
-
JPhysicsSetting,记录物体系统用到的配置信息,包含重力是多少,以及哪些layer和哪些layer会发生碰撞。
碰撞信息的收集和处理
先来思考一个问题,如何知道当前帧有哪些碰撞体进入到了其他碰撞体的检测范围或者是离开了其他碰撞体的检测范围?这个进入和退出肯定是只有第一次才会判定,,比如说第1帧A进入了B的范围,这个时候应该触发OnCollisionEnter事件,第2帧如果A还在B的范围,就不应该触发这个事件了。
为了实现这个功能,我们需要在某个特定时机收集当前帧都有哪些碰撞体和哪些碰撞体发生碰撞放在列表里,在全部收集完之后,和上一帧收集到的碰撞信息列表进行比较,如果多了上一帧的碰撞信息没有的,说明应该发送OnCollisionEnter事件,如果上一帧的某个碰撞信息在当前帧的列表没有了,说明应该发送OnCollisionExit事件。
这部分代码在JPhysicsManager中:
private void HandleCollidersEnter()
{
// New Collisions This Frame
foreach( var currentFrameCollision in _currentFrameHitColliders )
{
if( !_lastFrameHitColliders.Contains( currentFrameCollision ) )
{
//发送碰撞事件
this.ContactEvent( currentFrameCollision, true );
_lastFrameHitColliders.Add( currentFrameCollision );
}
}
......
}
private void ContactEvent( CollisionInfo collisionInfo, bool isBeginEvent )
{
if( collisionInfo.hitCollider == null || collisionInfo.collider == null )
{
return;
}
if( collisionInfo.collider.isTrigger || collisionInfo.hitCollider.isTrigger )
{
// Trigger Event
this.SendCollisionMessage( collisionInfo, isBeginEvent, true );
}
else
{
// Collison Event
this.SendCollisionMessage( collisionInfo, isBeginEvent, false );
}
}
剩下的问题就是在哪个时机进行收集和处理碰撞信息。这一点只要参照unity自己的顺序就可以了。
【雨松Mono:Unity声明周期图】
- FixedUpdate,刚体进行移动碰撞检测,JPhysicsManager收集碰撞信息
//In JPhysicsManager
private void FixedUpdate()
{
foreach( var pair in _rigidbodies )
{
var rigidbody = pair.Value;
if( !rigidbody.isActiveAndEnabled || !rigidbody.gameObject.activeInHierarchy )
{
continue;
}
rigidbody.Simulate( Time.fixedDeltaTime );
......
}
}
- WaitForFixedUpdate,处理碰撞检测信息,发送OnCollisionXXX等碰撞检测事件
//In JPhysicsManager
private IEnumerator UpdateCollisions()
{
while( true )
{
yield return _waitForFixedUpdate;
this.HandleCollidersEnter();
this.HandleCollidersExit();
_currentFrameHitColliders.Clear();
_currentFrameHitRigidbodies.Clear();
}
}
刚体的碰撞检测
接下来要讲的是刚体类的Simulation方法里都做了什么事情:
public override void Simulate( float deltaTime )
{
base.Simulate( deltaTime );
//受重力的影响
var gravity = _physicsManager.setting.gravity;
var gravityRatio = gravityScale * deltaTime;
_velocity.x += gravity.x * gravityRatio;
_velocity.y += gravity.y * gravityRatio;
_movement.x = _velocity.x * deltaTime;
_movement.y = _velocity.y * deltaTime;
// 在碰撞检测前,重置一些状态
this.ResetStatesBeforeCollision();
if( this.selfCollider == null || !this.selfCollider.enabled )
{
return;
}
//碰撞检测
this.CollisionDetect();
//移动位置
this.Move();
//根据配置检测结果调整速度,比如水平方向撞到了物体那么水平方向速度为0
this.FixVelocity();
// 在碰撞检测后,重置一些状态
this.ResetStatesAfterCollision();
}
重点在于碰撞检测函数 CollisionDetect。
如图,一个物体从左下角移动到右上角,那么它的检测范围应该是虚线包裹的区域。
如果要准确的检测得使用类似unity的BoxCast,会比较麻烦且耗性能,所以我使用了一种方法来近似这个过程。 用若干条RayCast来近似达到BoxCast的效果。
- 水平方向上:射线起点在右边缘上(如果是往左移动则是在左边缘上),射线长度是当前帧这个刚体水平方向移动的距离
- 竖直方向上:射线起点在上边缘上(如果是往下移动则是在下边缘上),射线长度是当前帧这个刚体垂直方向移动的距离
接下来考虑刚体静止不动的情况,如图,当刚体不动时,正好贴着墙,按照刚才发射线的方式,射线长度是0,会判断为 什么也检测不到,肯定是错误的。所以在静止不动时,会有一个最小射线长度。
此时又会产生新的问题,比如图中这种情况,贴着墙的情况往右移动,竖直方向上的射线会检测到墙壁,这是不对的,正确的碰撞结果应该是只有右侧碰撞到了墙壁。
这个问题的解决办法是把射线起点放在碰撞体的内部。
水平方向的碰撞检测核心代码如下:
_expandWidth代表最小射线长度,_shrinkWidth代表射线起点往里缩的距离
var rayOrigin = ( directionX == 1 ) ? _raycastOrigins.bottomRight : _raycastOrigins.bottomLeft;
var rayLength = Mathf.Abs( _movement.x ) + _shrinkWidth;
if( _movement.x == 0f )
{
rayLength += _expandWidth;
}
for( int i = 0; i < this.horizontalRayCount; i++ )
{
_raycastDirection.x = 1.0f;
_raycastDirection.y = 0.0f;
_raycastDirection.x *= directionX;
_raycastDirection.y *= directionX;
var hitCount = Physics2D.RaycastNonAlloc( rayOrigin, _raycastDirection, _raycastHit2D, rayLength, this.collisionMask );
for( int j = 0; j < hitCount; j++ )
{
var hit = _raycastHit2D[j];
if( _ignoredColliders.Contains( hit.collider ) )
{
continue;
}
HandleHorizontalHitResult( hit.collider, hit.point, hit.distance, directionX );
}
rayOrigin.y += _horizontalRaySpace;
}
HandleHorizontalHitResult填充碰撞信息,把碰撞信息添加到JPhysicsManager中,还需要调整刚体的移动距离:
private void HandleHorizontalHitResult( Collider2D hitCollider, Vector2 hitPoint, float hitDistance, int directionX )
{
//Trigger
if( HitTrigger( hitCollider, hitPoint, directionX, null ) )
{
return;
}
// Collision Info
_collisionInfo.collider = this.selfCollider;
_collisionInfo.hitCollider = hitCollider;
_collisionInfo.position = hitPoint;
// Collision Direction
if( directionX == -1 )
{
_collisionInfo.isLeftCollision = true;
}
if( directionX == 1 )
{
_collisionInfo.isRightCollision = true;
}
//Push Collision
if( !_currentDetectionHitColliders.Contains( hitCollider ) )
{
_physicsManager.PushCollision( _collisionInfo );
_currentDetectionHitColliders.Add( hitCollider );
}
//Fix movement
if( _movement.x != 0.0f )
{
if( Mathf.Abs( hitDistance - _shrinkWidth ) < Mathf.Abs( _movement.x ) )
{
_movement.x = ( hitDistance - _shrinkWidth ) * directionX;
}
}
}
为什么要调整移动距离?看下面这张图,上面的物体上一帧还在平台上面,按照它的移动速度,当前帧它将会穿过平台,此时就必须把它的位置调整到刚好贴合平台上。
由于篇幅有限,在本篇文章中射线检测我们暂时使用unity的Physics2D提供的方法。在之后的文章中,我们会使用一个四叉树结构对场景空间中的物体进行管理,然后用自己来实现射线检测。
这就是本节全部内容。
github工程
对应的是Test/Scenes/CollisionEvent
关于作者:
- 水曜日鸡,简称水鸡,ACG宅。曾参与索尼中国之星项目研发,具有2D联网多人动作游戏开发经验。
CSDN博客:https://blog.csdn.net/j756915370
知乎专栏:https://zhuanlan.zhihu.com/c_1241442143220363264
交流学习群:891809847