如何进行相交测试
在第一期文章中,我们最后留下来一个问题:如何判断一条射线和哪些物体相交?
这个问题分为两步来解决:
- 使用空间场景划分找到可能相交的物体(粗测阶段)。(找到1,2,3这三个物体)
- 对上一步找到的物体进行逐个遍历,判断出是否相交(精测阶段)。(对1,2,3进行遍历,最终只有1是相交的)
粗测阶段
粗测阶段首先需要用一个AABB把射线包裹起来。这一步很简单,注意当射线完全垂直或者水平时需要有一个最小宽度和最小高度。
/// <summary>
/// CreateRect By a 2 vector2 point
/// </summary>
public static Rect CreateRect( Vector2 start, Vector2 end )
{
var segment = end - start;
var rect = new Rect();
rect.x = Mathf.Min( start.x, end.x );
rect.y = Mathf.Min( start.y, end.y );
rect.width = Mathf.Abs( segment.x );
rect.height = Mathf.Abs( segment.y );
if( rect.width == 0f )
{
rect.width = MINRECTWIDTH;
}
if( rect.height == 0f )
{
rect.height = MINRECTHEIGHT;
}
return rect;
}
第二步是遍历四叉树,有按广度遍历和按深度遍历两种方法,我这里采用按广度遍历。考虑到某些同学可能不知道如何按广度遍历,我这里解释一下:
比如如下二叉树,按广度遍历就是8,6,10,5,7,9,11。而按深度遍历有前序遍历、中序遍历、后序遍历,这里就不展开了。广度遍历的做法就是使用一个队列来存储将要遍历的节点。遍历到8时,把左节点6和右节点10放到队列,遍历到6时把左节点5和右节点7放到队列,当一个节点是叶节点时不处理,不断循环,直到队列里没有东西。
在我们这里松散四叉树的情况,为了剪掉不必要的分支,当节点的松散范围不与当前检测的AABB相交时或者它和它的所有子节点包含的物体个数为0时,我们可以不要考虑这个节点。
最后代码如下:
//QuadTree.cs
public List<IQuadTreeItem> GetItems( Rect rayRect )
{
_cacheItemsFound.Clear();
_traverseNodeQueue.Clear();
AddNodeToTraverseList( _root, rayRect );
while( _traverseNodeQueue.Count > 0 )
{
var currentChild = _traverseNodeQueue.Dequeue();
foreach( IQuadTreeItem item in currentChild.items )
{
if( item.rect.Intersects( rayRect ) )
{
_cacheItemsFound.Add( item );
}
}
if( currentChild.isLeaf == false )
{
foreach( var node in currentChild.childNodes )
{
AddNodeToTraverseList( node, rayRect );
}
}
}
return _cacheItemsFound;
}
private void AddNodeToTraverseList( QuadTreeNode node, Rect rayRect )
{
if( node.totalItemsCount > 0 && node.looseRect.Intersects( rayRect ) )
{
_traverseNodeQueue.Enqueue( node );
}
}
Rect.Intersets是我写的扩展方法,用来判断两个AABB是否相交,原理也很简单,比较2个AABB的xMin,xMax,yMin,yMax。
public static bool Intersects( this Rect rect, Rect target )
{
if( rect.yMin > target.yMax )
{
return false;
}
if( rect.yMax < target.yMin )
{
return false;
}
if( rect.xMax < target.xMin )
{
return false;
}
if( rect.xMin > target.xMax )
{
return false;
}
return true;
}
在碰撞检测领域,AABB和AABB是否相交是最简单的问题了,往上的还有三角形和三角形相交,多边形和多边形相交,圆和多边形相交等等。它们会用到一个凸体基本理论:若两个凸体不相交,则必定存在一个平面,使两个凸体分别处于两边。 这些平面通常是物体的切平面,而取平面的法向量做轴,将物体投影与这些轴上,如果每个轴上的投影都存在重叠,则两个凸体必然相交。只要有一个不重叠,它们就不相交。这个轴就叫分离轴,这个定理叫分离轴定理。(这些内容属于扩展知识,平常基本用不到,但是如果面试问到了就是你装逼的机会了。) 具体可以看这篇文章:《碰撞检测之分离轴定理算法讲解》关于碰撞检测更多的知识,可以看 《实时碰撞检测算法技术》 这本书。
(左图不相交,右图相交)
精测阶段
经过上一步,我们可以知道射线可能相交的物体有1,2,3这三个物体。接下来逐个判断射线是否与这三个物体是否相交。
那么,如何判断射线与AABB是否相交呢? 这个问题可以转化成射线与AABB的四条边是否相交,进而转化成射线与线段是否相交。
首先根据射线起点和终点求出射线方程:
var destPoint = origin + dir * distance;
//ax + by + c = 0
var a = destPoint.y - origin.y;
var b = origin.x - destPoint.x;
var c = destPoint.x * origin.y - destPoint.y * origin.x;
然后我以AABB的左边举例,把xMin代入直线方程,如果对应的y值处于yMin和yMax之间说明有交点,注意b不能等于0,因为需要求出第一个交点在哪,所以需要计算每个交点与起点的距离,最近的那个交点就是第一个交点:
//y = (-c - a * x )/b
if( b != 0f )
{
if( xMin < x2 && xMin > x1 )
{
tempHitPoint.x = xMin;
var y = ( -c - a * xMin ) / b;
if( y < yMax && y > yMin )
{
tempHitPoint.y = y;
hit = true;
var dis = ( tempHitPoint - origin ).magnitude;
if( dis < hitDistance )
{
hitPoint = tempHitPoint;
hitDistance = dis;
}
}
}
}
其他三条边使用相同的原理。 对应的演示场景在Test/Scenes/RaycastAABB。
总结
这样,我们就能实现Unity的Raycast类似功能的方法了:
public static void Raycast( QuadTree quadTree, Vector2 origin, Vector2 direction, ref JRaycastHitList hitList, float distance, int layMask )
{
var hitCount = 0;
var ray = direction.normalized * distance;
var destPoint = origin + ray;
//根据射线起点和终点构造AABB
var rayRect = CreateRect( origin, destPoint );
//粗测阶段,得到可能相交的AABB列表
var itemList = quadTree.GetItems( rayRect );
//精测阶段,同时考虑layMask
foreach( IQuadTreeItem item in itemList )
{
var collider = item.selfCollider;
var layer = collider.gameObject.layer;
if( !layMask.Contains( layer ) )
{
continue;
}
if( !collider.gameObject.activeInHierarchy )
{
continue;
}
if( !collider.enabled )
{
continue;
}
//计算一条射线与AABB是否相交
CalculateRayHit( collider, origin, direction, ref hitList, distance, ref hitCount );
}
}
这里还用到我自己写的一个layerMask功能,就是用来设置哪些层和哪些层需要计算碰撞的功能。如图,Default层只和Default、Character、Platform层进行碰撞。脚本在Scripts\Editor\JPhysicsSettingInspector。 使用了EditorGUILayout.MaskField的API。
以上就是整个2D物体系统系列文章的全部内容了,github工程。从上个月底到现在从项目源码和写博客花了大概半个多月时间了。因为时间有限只做了最基础的功能,大家看这些文章主要还是以学习为主,实战中需要亲自搭建物理系统的需求还是少。那么为什么我还要写这系列文章?
为了提高知识的广度,从而让自己在未来面试时有更多优势。像是物体相交测试的分离轴定理,在米哈游和字节的高级客户端岗位面试时都可能会问到。当每个人的业务能力差不多的时候,比拼的就是理论知识。其实我很讨厌内卷这个词,不过是竞争激烈换了个说法。在如今越来越内卷的程序员行业,只有不断学习和精进,才能避免自己被淘汰。
说回文章,从第一篇的框架搭建,到第二篇的空间划分,到这一篇的相交测试,考虑到受众和我的时间有限,每一篇的内容其实深度都不是特别深,选择四叉树只是因为实现起来比较简单,同时能复习到关于二叉树的知识,在我的演示项目里四叉树效率并不高,比unity的射线检测差了差不多4~5倍左右吧,实战中需要进一步优化四叉树的查询算法或者选用其他更合适的数据结构。相信大家全部看完后对于物理系统有着更深刻的理解。我是水鸡,我们下期再见。
关于作者:
- 水曜日鸡,简称水鸡,ACG宅。曾参与索尼中国之星项目研发,具有2D联网多人动作游戏开发经验。
CSDN博客:https://blog.csdn.net/j756915370
知乎专栏:https://zhuanlan.zhihu.com/c_1241442143220363264
交流学习群:891809847