文章目录
前言
在开发视频游戏时,一个常见的操作是确定从特定点是否可以看到或触摸到一个物体——在菜单屏幕上点击一个按钮,或者在即时战略游戏中点击一辆坦克就是这样的例子。有时我们可能希望确定一个 AI 单位从其当前位置是否能“看到”另一个玩家单位。虽然这些 AI 可见性检查和菜单鼠标点击在表面上看起来可能不太相似,但它们通常都是通过相同的过程实现的——光线投射。光线投射允许我们从世界中的特定点(无论是坦克的炮塔还是玩家的鼠标指针)发射无限细的光线,并查看它在途中与哪些物体碰撞。根据碰撞到的物体,我们可以调用任何我们需要的自定义代码来为我们的游戏场景添加交互性。
在本教程中,笔者将带领你们看到如何在数学上定义一条光线,如何构建朝着我们想要的方向的光线,并查看允许我们确定光线是否穿过一个简单形状的相交测试。
1. 光线
一条标准的光线由空间中的一个位置和在空间中行进的一个方向组成。我们可以把这想象成一个激光指针——我们放置它,然后从它发射一束激光,激光沿直线行进,最终击中某个东西。在代码中,一条光线只是两个Vector3
对象,其中一个将被归一化以表示行进方向。我们可以想象,我们的方向是一条从光线在空间中的原点向外延伸的无限直线——光线投射的目的就是查看世界中的哪些物体与这条无限直线相交。
从变换矩阵生成光线
我们当然可以在我们模拟的“世界”中的任何地方定义一条光线并查看它击中了什么,但通常我们希望从世界中的某个已知点开始我们的光线,例如一个现有的物体。因此,能够使用物体的模型矩阵来定义光线是非常有用的。让我们回忆一下模型矩阵包含什么:
[
1
0
0
p
x
0
1
0
p
y
0
0
1
p
z
0
0
0
1
]
\begin{bmatrix} 1 & 0 & 0 & p_x\\ 0 & 1 & 0 & p_y\\ 0 & 0 & 1 & p_z\\ 0 & 0 & 0 & 1 \end{bmatrix}
100001000010pxpypz1
在右边我们有物体的世界空间位置,而上面的 3x3 矩阵包含物体的世界空间旋转。从这个上面的 3x3 区域,我们可以确定物体面向的方向。如果我们假设 -z 是“向前”方向(如在 OpenGL 坐标中),那么我们可以通过否定上面 3x3 区域的第三行或第三列来形成“向前”向量 [0,0,-1]——但是哪一个是正确的呢?我们可以通过检查另一个示例模型矩阵来确定这一点,这个矩阵将物体旋转,使其向左看 45°:
[
x
x
y
x
y
z
p
x
x
y
y
y
z
y
p
y
x
z
y
z
z
z
p
z
0
0
0
1
]
=
[
0.7071
0
0.7071
p
x
0
1
0
p
y
−
0.7071
0
0.7071
p
z
0
0
0
1
]
\begin{bmatrix} x_x & y_x & y_z & p_x\\ x_y & y_y & z_y & p_y\\ x_z & y_z & z_z & p_z\\ 0 & 0 & 0 & 1 \end{bmatrix} =\begin{bmatrix} 0.7071 & 0 & 0.7071 & p_x\\ 0 & 1 & 0 & p_y\\ -0.7071 & 0 & 0.7071 & p_z\\ 0 & 0 & 0 & 1 \end{bmatrix}
xxxyxz0yxyyyz0yzzyzz0pxpypz1
=
0.70710−0.7071001000.707100.70710pxpypz1
从这里,我们可以看到,当取负时,矩阵值
[
x
z
,
y
z
,
z
z
]
[x_z, y_z, z_z]
[xz,yz,zz]给我们正确的方向向量
[
0.7071
,
0
,
−
0.7071
]
[0.7071,0,-0.7071]
[0.7071,0,−0.7071] 来表示这个新方向。由此,我们可以推断出,从任何物体的模型矩阵中,
[
x
x
,
y
x
,
z
x
]
[x_x,y_x,z_x]
[xx,yx,zx] 将指向该物体在世界空间中的“右轴”,
[
x
y
,
y
y
,
z
y
]
[x_y,y_y,z_y]
[xy,yy,zy] 将指向上方,
[
x
z
,
y
z
,
z
z
]
[x_z,y_z,z_z]
[xz,yz,zz] 将指向前方。
鼠标指针发出的光线
有时候,我们可能想要从相机的视角定义一条光线。一种方法是使用视图矩阵——不过这不像使用模型矩阵那么直接。回想一下,视图矩阵可以被认为是模型矩阵的“逆”,所以要获取视图矩阵的位置和方向,必须先将其反转以将其转换为模型矩阵。如果我们确定视图矩阵具有统一的缩放比例,那么矩阵的转置也能起到同样的作用,所以视图矩阵的“前向”轴可以用 [ x x , x y , x z ] [x_{x}, x_{y}, x_{z}] [xx,xy,xz]提取出来(记住,转置会翻转行和列,所以如果我们已经知道要提取哪些数字,就不必进行“完全”转置)。
通常我们不想直接从正前方发出一条光线,而是点击屏幕上的某个东西——无论是角色扮演游戏中的菜单框,还是即时战略游戏中的坦克工厂,我们常常需要能够以某种方式检测到鼠标指针下是什么。如果我们处理的是 3D 视图,那就意味着我们也需要处理投影矩阵——记住,透视矩阵有一个视野范围,它定义了我们能看到多“远到旁边”,以及从相机发出的光线从屏幕中心能指向多“侧向”。
假设我们有鼠标的屏幕位置,我们可以算出那个位置在世界空间中的位置。如果你还记得在延迟渲染教程中,我们做了完全相同的事情——给定一个屏幕片段位置,我们可以通过用视图投影矩阵的逆来变换屏幕空间位置,然后除以我们在矩阵乘法中得到的“逆”(w),从而得到一个世界空间位置。换句话说,我们正在将位置反投影回世界坐标。这给了我们一个位置,但是如何得到一个“方向”呢?
要算出这个方向,值得看一下由视图投影矩阵形成的视锥体:
再回想一下延迟渲染教程——我们用来进行反投影的不只是屏幕的
x
x
x和
y
y
y坐标,我们还利用了深度样本,从而也得到了一个
z
z
z坐标。要从一个屏幕位置形成一条光线,我们可以对两个位置执行这个反投影过程,这两个位置具有相同的
x
x
x和
y
y
y坐标,但
z
z
z轴坐标不同;对这两个位置进行简单的相减并归一化应该能给我们一条光线。但是要用哪两个
z
z
z轴坐标呢?正如我们现在应该熟悉的那样,我们有一个“近平面”和一个“远平面”,它们界定了特定方向上所有可见的东西。在 OpenGL 中,以及对于我们一直在使用的矩阵,这个近平面在
z
z
z轴上映射到归一化设备坐标(NDC)的
−
1
-1
−1,而远平面映射到 NDC 的
+
1
+1
+1。因此,如果我们将 NDC 坐标
[
x
,
y
,
−
1
]
[x,y,-1]
[x,y,−1]和
[
x
,
y
,
1
]
[x,y,1]
[x,y,1]反投影到世界坐标中,我们就可以在世界空间中形成一个穿过屏幕上一个点的方向向量:
在这个例子中,我们在屏幕的 x 轴方向上大约点击了距离屏幕边缘 75%的位置。如果我们使用视图投影矩阵的逆矩阵在深度为 -1 和 1 的位置反投影这个坐标,我们最终会得到世界空间坐标,在经过变换后它们会被除以“逆”
w
w
w。从这些坐标中,可以确定一条射线。在这个特定的例子中,我们可以看到这条射线穿过了立方体,但没有击中球体。
计算逆视图矩阵
反投影的过程需要视图投影矩阵的逆矩阵——即使对于一个 4x4 的矩阵,求逆的过程也是相当耗费资源的。对于视图矩阵和投影矩阵,我们知道是什么值构成了它们(或者至少可以把这些值存储在我们的类中的某个地方),这使得我们可以推导出一个矩阵,它的作用与逆矩阵相同,而不需要对我们的矩阵进行通用的求逆过程。
在我们从上一个模块就开始使用的用来操作视图矩阵的 Camera 类中,我们一直在分别存储俯仰角、偏航角和位置,以便在按下按键和移动鼠标时更容易地改变这些值。然后,我们在需要的时候使用 BuildViewMatrix 方法生成视图矩阵,这个方法执行以下操作:
Matrix4 Camera::BuildViewMatrix() const {
return Matrix4::Rotation (-pitch , Vector3 (1 , 0 , 0)) *
Matrix4::Rotation (-yaw , Vector3 (0 , 1 , 0)) *
Matrix4::Translation (-position );
}
如果我们颠倒这些矩阵乘法的顺序,并使用俯仰角、偏航角和位置成员变量(注意,BuildViewMatrix 方法已经对成员变量取反了,因为它们已经在世界空间中定义,必须“取反”才能得到视图矩阵),那么我们可以得到一个模型矩阵(并且位置成员变量也给我们提供了一条射线的起点):
Matrix4 cameraModel = Matrix4::Translation(position) *
Matrix4::Rotation (yaw , Vector3 (0 , 1 , 0)) *
Matrix4::Rotation (pitch , Vector3 (1 , 0 , 0));
计算逆投影矩阵
计算投影矩阵的逆矩阵稍微复杂一些,但我们只需要定义投影矩阵时用到的相同变量——纵横比、视野角度、近平面和远平面。这里提醒一下透视投影矩阵的样子:
[
f
aspect
0
0
0
0
f
0
0
0
0
z
N
e
a
r
+
z
F
a
r
z
N
e
a
r
−
z
F
a
r
2
⋅
z
N
e
a
r
⋅
z
F
a
r
z
N
e
a
r
−
z
F
a
r
0
0
−
1
0
]
\begin{bmatrix} \frac{f}{\text{aspect}} & 0 & 0 & 0 \\ 0 & f & 0 & 0 \\ 0 & 0 & \frac{z_{Near}+z_{Far}}{z_{Near}-z_{Far}} & \frac{2\cdot z_{Near}\cdot z_{Far}}{z_{Near}-z_{Far}} \\ 0 & 0 & -1 & 0 \end{bmatrix}
aspectf0000f0000zNear−zFarzNear+zFar−100zNear−zFar2⋅zNear⋅zFar0
其中
f
f
f 是视野角度的余切。如果我们更一般地将投影矩阵定义为:
[
a
0
0
0
0
b
0
0
0
0
c
d
0
0
e
0
]
\begin{bmatrix} a & 0 & 0 & 0 \\ 0 & b & 0 & 0 \\ 0 & 0 & c & d \\ 0 & 0 & e & 0 \end{bmatrix}
a0000b0000ce00d0
我们可以看到,矩阵中的部分
a
a
a 和
b
b
b 不与任何其他轴相互作用,因为它们在对角线上,并且它们的行/列上没有其他元素。因此,要“撤销”它们在变换过程中对向量的影响,我们可以使用它们的倒数。部分
d
d
d 和
e
e
e 稍微复杂一些——部分
d
d
d 给出了
z
z
z 轴和
w
w
w 轴之间的相互作用,而部分
e
e
e 将输入向量的
z
z
z 轴映射到结果向量上。要“撤销”这些,我们不仅要取它们的倒数,还必须对它们进行转置,以便将值映射回它们的原始轴上,所以部分
e
e
e 从第 4 行第 3 列变为第 3 行第 4 列,而部分
d
d
d 则相反。部分
c
c
c 是最难撤销的——在投影矩阵中,它用于缩放写入深度缓冲区的
z
z
z 值,该值定义了一个片段的距离有多远,但由于这是我们现在提供的值,我们需要某种方法用它来缩放所有其他坐标以“反转”它的用途。为此,部分
c
c
c 需要进入结果向量的
w
w
w 轴,这样我们就可以进一步用“逆
w
w
w”除它,将所有坐标从归一化设备坐标空间拉伸回世界空间。我们还需要撤销原本会添加到
z
z
z 轴上的平移,所以这也会进入我们的
w
w
w 结果中——总的来说,我们得到以下矩阵来反转投影:
[
1
a
0
0
0
0
1
b
0
0
0
0
0
1
0
0
1
e
−
c
d
e
]
\begin{bmatrix} \frac{1}{a} & 0 & 0 & 0 \\ 0 & \frac{1}{b} & 0 & 0 \\ 0 & 0 & 0 & 1 \\ 0 & 0 & \frac{1}{e} & -\frac{c}{de} \end{bmatrix}
a10000b100000e1001−dec
2.碰撞体积
形成一条射线是一回事,但我们还需要能够确定这条射线与什么相交。如果我们愿意,可以针对一条射线测试一个物体网格的每个三角形,因为有一个常见的计算方法来确定一条射线是否与一个三角形相交,但在很多情况下这通常是过度计算——在即时战略游戏中,我们并不关心我们点击的是坦克的炮塔还是履带,我们只是想确定我们是否点击了特定的单位。通常情况下,我们会尝试确定我们是否与一个大致包围我们想要进行射线投射的物体的形状相交,比如一个球体或一个盒子形状。在整个教程系列中,这些大致的近似形状将被使用,以便我们能够以有效的方式计算物体之间的物理交互,所以对这些形状进行射线投射可以作为对它们的一个很好的介绍。
平面
我们已经使用了一种碰撞形状,尽管它实际上并不是一个真正的体积。在整个图形模块中,我们已经看到了视锥体如何可以用一组六个平面来表示,每个平面将空间分成两个子空间,有点像一个无限大、无限薄的墙。所以我们可以用它们形成体积(比如我们的视锥体),并且平面通常是我们在这些教程中将要看到的碰撞测试的一部分。
我们可以用四个值来表示一个平面,经典的平面方程如下:
A
x
+
B
y
+
C
z
+
D
=
0
Ax + By+ Cz + D = 0
Ax+By+Cz+D=0
如果平面值
A
B
C
D
ABCD
ABCD 和
[
x
,
y
,
z
,
1
]
[x,y,z,1]
[x,y,z,1] 的点积等于 0,那么空间中位于位置
x
,
y
,
z
x,y,z
x,y,z 的任何点都被认为在平面上。此外,如果点积的结果大于 0,我们可以认为该点在平面内部(或前面),如果点积小于 0,则该点在平面外部(或后面)。
平面在游戏中经常被用作游戏世界的绝对边界——有时游戏关卡中的漏洞意味着玩家可以逃离游戏世界并穿过地板,所以游戏开发者经常在地板下面放置“死亡平面”,如果玩家的世界位置在“错误”的一侧,这些平面就会连接到自动杀死玩家的代码上。
球体
我们也可以用四个值来表示一个球体——一个表示球体中心的向量位置 p p p,以及一个半径 r r r。因此,空间中任何距离 x , y , z x,y,z x,y,z 小于 r r r 的点都在球体内,如果距离等于 r r r,则该点在球体表面上。
盒子(长方体)
盒子比球体稍微复杂一些。在碰撞检测中有两种类型的盒子(有时称为包围盒)——轴对齐包围盒(AABBs)和有向包围盒(OBBs)。
AABBs(轴对齐包围盒)
一个轴对齐包围盒在三维空间中有一个位置,还有一个尺寸,用于定义盒子的长、宽和高。通常这些以“半尺寸”的形式存储,这意味着从盒子的中心到边缘的距离。
无论用 AABB 表示的对象处于什么方向,AABB 永远不会旋转——所以它的
x
x
x 轴总是沿着世界空间的
x
x
x 轴指向,
y
y
y 轴和
z
z
z 轴也是如此。这使得对 AABB 进行计算稍微简单一些,因为盒子和我们用它来测试的任何其他结构(在这种情况下是一条射线,或者是另一个用于检测成对物体之间碰撞的碰撞体积)将始终处于相同的“空间”中。
OBBs(有向包围盒)
用 AABB 表示一个对象的包围体积的问题在于,随着形状的方向随时间变化(由于玩家移动或物理力作用于它),盒子的大小将不能反映对象在世界坐标轴上的新范围:
随着上面的形状旋转,其碰撞体积的 AABB 匹配度会逐渐变差,所以对其进行的任何碰撞检测或射线投射都将不准确。我们可以通过将半尺寸存储为一个向量,然后用对象模型矩阵的上 3x3 区域对其进行变换,从而扩展具有原点和半尺寸的盒子的概念,使其也具有方向,从而得到一个有方向的包围盒。那么,如果 OBB 能更好地匹配对象的形状,我们为什么还要使用 AABB 呢?正如我们将在本教程系列中看到的,AABB 的一些特性使得它们之间的碰撞检测更容易,所以除非需要更高的精度,否则 AABB 通常比 OBB 更受欢迎。
3. 射线相交
虽然上面的碰撞形状不是唯一常见的形状,但它们确实是理解形状和射线之间的碰撞如何工作的一个很好的起点。现在让我们来看一些用于确定射线是否与这些形状中的任何一个相交的算法,以及我们可以从它们中获得什么信息。
射线/平面相交
最容易计算的相交测试是射线与平面的相交。除非平面的法线和射线的方向指向相同的方向,否则射线和给定平面之间最终总会有一个交点,可以这样计算:
t
=
−
(
R
a
y
p
o
s
⋅
P
l
a
n
e
a
b
c
+
p
l
a
n
e
d
)
R
a
y
d
i
r
⋅
P
l
a
n
e
a
b
c
t=\frac{-(Raypos\cdot Plane_{abc}+planed)}{Raydir\cdot Plane_{abc}}
t=Raydir⋅Planeabc−(Raypos⋅Planeabc+planed)
p
=
R
a
y
p
o
s
+
t
(
R
a
y
d
i
r
)
p = Raypos + t(Raydir)
p=Raypos+t(Raydir)
这使我们能够得到沿射线到交点的长度
t
t
t,然后从那里得到实际的交点
p
p
p。在编写射线/平面相交的代码时,你可能希望先计算
R
a
y
d
i
r
⋅
P
l
a
n
e
a
b
c
Raydir\cdot Plane_{abc}
Raydir⋅Planeabc,因为这将检查平面法线和射线方向之间的正交性,这样在这些情况下你可以避免除以 0。
射线/三角形相交
在某些情况下,我们可能想要对三角形进行射线相交测试——一些游戏在模型被射击时会应用逼真的“弹孔”贴花,其位置可以通过沿着子弹轨迹进行射线投射来确定。
进行射线/三角形相交的第一步是从三角形中形成一个平面——三角形总是平面,所以我们可以利用这一点使计算更简单。要计算三角形的平面,我们需要三角形的法线,正如你可能从前面的模块中记得的,可以从两个向量和一个叉积计算得到:
三角形的法线为我们提供了平面系数
P
l
a
n
e
a
b
c
Plane_{abc}
Planeabc,同时对三角形的任意一点和法线进行点积运算可以得到
p
l
a
n
e
d
planed
planed。从那里,我们可以执行前面描述的射线/平面相交来找到射线与“三角形平面”相交的点。
从此,我们可以形成另外三个平面——不是沿着三角形的表面,每个平面沿着三角形的一条边掠过。只有当三角形平面上的点在这三个平面的每一个内部时,这个点才被认为在三角形内部。如果这个点在平面内,那么我们就有了交点,以及离射线原点的距离,就像之前一样。为了形成额外的平面,我们可以通过沿着三角形的法线从任何一个角移动来确定它们上的额外点——这将为我们提供三个点,就像我们上面得到三角形法线一样使用。
射线与球体的相交
射线与球体的相交和与三角形的相交有点不同,但也不是太难。在这种情况下,我们要通过将球体的中心投影到射线的方向向量上,来计算光线上离球体最近的点。这听起来很难,但其实只是一个点积和一些向量乘法:
设
:
d
i
r
⃗
=
s
p
o
s
⃗
−
r
p
o
s
⃗
(
s
p
o
s
⃗
是球心位置向量,
r
p
o
s
⃗
是光线起点位置向量
)
设:\vec{dir}=\vec{s_{pos}}-\vec{r_{pos}} (\vec{s_{pos}}是球心位置向量,\vec{r_{pos}}是光线起点位置向量)
设:dir=spos−rpos(spos是球心位置向量,rpos是光线起点位置向量)
p
r
o
j
⃗
=
r
d
i
r
⃗
⋅
d
i
r
⃗
(
r
d
i
r
⃗
是光线方向向量,点积运算)
\vec{proj}=\vec{r_{dir}}\cdot\vec{dir} (\vec{r_{dir}}是光线方向向量,点积运算)
proj=rdir⋅dir(rdir是光线方向向量,点积运算),
p
⃗
=
r
p
o
s
⃗
+
p
r
o
j
⃗
⋅
r
d
i
r
⃗
\vec{p}=\vec{r_{pos}}+\vec{proj}\cdot\vec{r_{dir}}
p=rpos+proj⋅rdir
一旦我们有了这个点,我们就可以通过计算从球心到点
p
p
p的距离来快速确定光线是否与球体相交——如果这个距离小于球体的半径,那么光线就与球体相交。在这种情况下,如果我们沿着光线方向
r
D
i
r
rDir
rDir移动
p
r
o
j
proj
proj个单位得到最近点
p
p
p,我们可以看到它离球体的距离大于球体的半径,因此我们的光线没有与球体相交。
这可以告诉我们光线是否相交,但我们通常还需要知道交点在光线上的位置有多远。你可能会认为
r
P
o
s
rPos
rPos和
p
p
p之间的距离会给我们正确的答案,但这并不完全正确。
当光线确实与球体相交时,上述用于返回点
p
p
p的计算仍然给我们球体中心与光线方向之间的最近点——所以在这种情况下,点
p
p
p最终在球体内。
要计算实际的光线相交距离 d d d,我们需要进一步的计算:
d = ∥ p ⃗ − r p o s ⃗ ∥ − r 2 − ( ∥ p ⃗ − s p o s ⃗ ∥ 2 ) d=\|\vec{p}-\vec{r_{pos}}\|-\sqrt{r^{2}-(\|\vec{p}-\vec{s_{pos}}\|^{2})} d=∥p−rpos∥−r2−(∥p−spos∥2)
也就是说,我们得到交点与光线之间的距离,并减去半径的平方——点 p p p与球心之间的距离。
射线与盒子的相交
检测盒子与光线之间的碰撞再次归结为平面相交测试。就像我们在上一个模块中的视锥体一样,我们可以使用 6 个平面来表示一个盒子,从而形成一个封闭的体积。
轴对齐包围盒(AABB)相交
要对轴对齐包围盒执行光线相交检测,我们可以计算光线与构成盒子的所有六个平面的交点,并确定是否有任何交点在盒子的表面上。根据我们之前对盒子的定义,即有一个位置和一个半尺寸,如果我们从盒子的点中减去我们想要测试的空间中的点的位置,那么如果所得向量在每个轴上的绝对位置小于盒子的半尺寸,那么测试点一定在盒子内部。
在这种情况下,点
a
a
a 和点
d
d
d(代表光线与轴对齐包围盒(AABB)的“左”平面和“底”平面相交的点)在 AABB 的表面之外,但点
b
b
b 和点
c
c
c(代表沿着“右”平面和“顶”平面的交点)确实与表面接触(点
b
b
b 在
x
x
x 轴上距离立方体原点正好是
s
i
z
e
.
x
size.x
size.x个单位,点
c
c
c 在
y
y
y 轴上等于
s
i
z
e
.
y
size.y
size.y个单位),因此我们知道存在一个交点。如果我们确实需要知道确切的交点,我们需要通过计算向量
b
−
r
P
o
s
⃗
\vec{b - rPos}
b−rPos和
c
−
r
P
o
s
⃗
\vec{c - rPos}
c−rPos的长度,并选择较短的长度来确定点
b
b
b 或点
c
c
c 哪个更接近
r
P
o
s
rPos
rPos。在这种情况下,点
b
b
b 应该是交点。
虽然我们可以测试所有六个面,但实际上这是不必要的。如果一条光线从盒子的右侧进入,那么光线总是会在碰到左侧平面之前先碰到右侧平面。
因此,我们可以利用光线的归一化方向向量的值,只对三个平面进行测试,而不是六个平面。对于每个轴,如果光线方向是正的,我们检查“负”平面(从原点减去尺寸);如果它是负的,我们测试“正”平面(向原点加上尺寸)。将测试数量减少到三个后,我们现在可以看到,最远的交点现在将是“最佳”选择,因为我们跳过了光线离开盒子后才会碰到的任何平面。在上面的例子中,即使点“
a
a
a”更近,我们也可以跳过检查它,因为点
b
b
b 在光线上更靠后。只要最远的交点在 AABB 的表面上(在相关轴上它离盒子的尺寸不更远),那么我们就找到了碰撞点。
有向包围盒(OBB)相交
有向包围盒(OBB)的相交检测更棘手。对于轴对齐包围盒(AABB),我们可以通过简单的单一值的加法和减法来获得平面偏移量,因为我们知道平面将与轴对齐——盒子的“右侧”总是相对于其位置有一个偏移量[某个距离,0,0]。但是,如果我们处理的是一个旋转的盒子,这就不再成立了,我们必须确定指向物体前方、上方和右侧的正确方向向量(或者换句话说,物体局部空间轴在世界空间中的映射)。我们可以像前面描述的那样从物体的变换矩阵中提取 x、y 和 z 轴,但是现在我们终究还是得测试六个平面,因为现在很难确定“最佳”的三个平面。
出于同样的原因,在投影后测试“最佳”点是否在盒子内也更难了——我们不能再每个轴只检查一个数字了!在这种情况下,最好用不同的方式来考虑问题——我们可以通过使用物体变换矩阵的逆矩阵来临时变换光线的位置和方向,将世界空间中的 OBB 测试转换为盒子局部空间中的 AABB 测试。
这会在物体的局部空间中给我们一个交点 a,所以如果我们需要这个交点,就需要再次用物体的模型矩阵对它进行变换,以将其变回“世界空间”——如果我们只需要知道光线是否相交,我们可以把这个点留在局部空间。这让你对使用有向包围盒的难度有了一些认识,当我们后面开始考虑物体碰撞时,它会变得更加复杂。这也是对我们在物理计算中常见操作的一个介绍——将某个位置 a 带入某个物体 b 的“局部空间”。如果我们只关心一个位置,我们可以从 b 的位置中减去 a 以得到 a 的相对位置。然而,如果我们也需要知道旋转,我们就必须使用物体模型矩阵的逆矩阵,因为通过这个矩阵进行变换会将我们带回到物体的局部空间。
4. 示例代码
碰撞体积类
在我们的代码中能够表示一个碰撞物体显然非常重要——上面的光线投射技术使用了不同类型的碰撞形状,并且随着教程系列的推进,我们也将研究如何检测它们之间的碰撞。在提供的代码库中,我们要测试的每个体积都将从 CollisionVolume 类派生,如下所示:
# pragma once
enum class VolumeType {
AABB = 1 , OBB = 2 , Sphere = 4 , Mesh = 8 ,
Capsule = 16 , Compound = 32 , Invalid = 256
};
class CollisionVolume {
public :
CollisionVolume () {
type = VolumeType::Invalid;
}
~ CollisionVolume () {}
VolumeType type ;
};
它没有太多内容,只是存储了一个枚举类型,我们可以用它来确定一个派生类是什么类型,而无需使用任何虚方法或动态类型转换。为了看看这是如何用来表示我们之前介绍过的实际碰撞体积类型之一的,下面是提供的 SphereVolume 类的样子:
# pragma once
#inclde " CollisionVolume .h"
class SphereVolume : CollisionVolume {
public :
SphereVolume(float sphereRadius = 1.0 f ) {
type = VolumeType :: Sphere ;
radius = sphereRadius ;
}
~ SphereVolume() {}
float GetRadius() const {
return radius;
}
protected :
float radius;
};
射线类
为了在 C++中表示光线的概念,我们有一个恰如其分地命名为 Ray 类的类。它没有太多内容:
class Ray {
public :
Ray ( Vector3 position , Vector3 direction );
~ Ray ( void );
Vector3 GetPosition() const { return position ; }
Vector3 GetDirection() const { return direction ; }
protected :
Vector3 position ; // World space position
Vector3 direction ; // Normalised world space direction
};
我们只需要两个Vector3 类来表示我们的光线;一个用于位置,一个用于它的方向——这个向量总是被假定为归一化的。除了获取器之外,这就是表示一条光线所需的全部内容。然而,要表示光线碰撞,事情就变得有点更有趣了。为此,还为你提供了一个 RayCollision 类,如下所示:
struct RayCollision {
void * node ; // Node that was hit
Vector3 collidedAt ; // WORLD SPACE pos of the collision !
float rayDistance ; // how far along the ray was the collision
RayCollision() {
node = nullptr ;
rayDistance = 0.0 f ;
}
}
这是一个相当直接的结构体——它允许我们存储碰撞点的世界位置,以及碰撞在光线上发生的位置距离(沿着光线多远发生碰撞)。我们还将通过保留一个空指针来存储指向“某个东西”的指针。根据光线的确切用途,它可能与任何对象类型发生碰撞(由我们来编写算法以确定与我们的任何类的碰撞是如何处理的),所以我们将存储一个空指针,允许我们在代码中保留指向任何东西的指针。只要我们的代码在使用这个指针时保持一致,一切都会正常工作,这种使用空指针的方法在许多物理引擎中间件中很常见,因为它们对特定的游戏及其类结构一无所知,所以通常只是接收或存储指向对使用该引擎的游戏有意义的一些内存的指针。
碰撞检测类
为了实际使用 Ray 类,并在光线和我们的碰撞体积之间进行相交测试,我们将向 CollisionDetection 类添加一些功能,这个类目前没有做太多事情,但确实为我们提供了一些计算逆投影和视图矩阵的函数,以及将屏幕位置“反投影”到世界空间的函数。它还有一组光线相交函数,这些函数接收一条光线、一个变换和一个包围体积,并根据是否发生碰撞返回真或假。不过一开始,这些函数中的每一个都只返回假,所以它们什么都不做!
为了使光线投射起作用,我们必须用一些适当的功能来填充这些函数。我们将从函数 RayIntersection 开始,这个函数的目的是获取传入的体积的类型,并调用适当的光线相交函数,以查看传入的光线是否与它发生碰撞。下面是 RayIntersection 函数的代码:
bool CollisionDetection::RayIntersection(const Ray & r , GameObject & object , RayCollision & collision ) {
const Transform & transform = object . GetConstTransform();
const CollisionVolume * volume = object . GetBoundingVolume();
if (! volume ) {
return false ;
}
switch ( volume - > type ) {
case VolumeType::AABB:
return RayAABBIntersection(r , transform , (const AABBVolume&)* volume , collision);
case VolumeType::OBB:
return RayOBBIntersection(r , transform , (const OBBVolume &)* volume , collision );
case VolumeType::Sphere:
return RaySphereIntersection(r , transform , (const SphereVolume &)* volume , collision );
}
return false ;
}
这个函数没有太多内容,但它帮助我们看到 GameObject 类如何持有一个 Transform 和一个 CollisionVolume(第 2 和第 3 行)。在这个代码库中,每个 GameObject 都被假定有一个变换,所以对这个对象的访问器将返回一个引用,而这个对象可能不可碰撞,因此可能没有 CollisionVolume,所以访问器改为返回一个指针,我们可以检查这个指针,如果没有就返回假,因为我们不可能与它发生碰撞。如果传入的 GameObject 确实有一个体积,我们可以根据它的类型变量进行切换,并调用适当的相交函数。这涉及将 CollisionVolume 转换为正确的子类——只要我们不在子类构造函数中修改类型变量,这将总是命中实际碰撞类型的“正确”的 switch 语句。
射线/球体相交代码
很好!现在来看看光线投射背后的理论如何在 C++中实现。我们将从最简单的开始,看看光线/球体碰撞,它应该在 RaySphereIntersection 函数中实现,如下所示:
bool CollisionDetection::RaySphereIntersection (const Ray &r ,const Transform & worldTransform , const SphereVolume & volume ,RayCollision & collision ) {
Vector3 spherePos = worldTransform.GetPosition();
float sphereRadius = volume.GetRadius();
// Get the direction between the ray origin and the sphere origin
Vector3 dir = ( spherePos - r . GetPosition());
// Then project the sphere ’s origin onto our ray direction vector
float sphereProj = Vector3::Dot( dir , r.GetDirection());
if( sphereProj < 0.0 f ) {
return false ; // point is behind the ray !
}
// Get closest point on ray line to sphere
Vector3 point = r.GetPosition() + ( r . GetDirection() * sphereProj );
float sphereDist = ( point - spherePos ). Length();
if ( sphereDist > sphereRadius ) {
return false ;
}
float offset = sqrt (( sphereRadius * sphereRadius ) - ( sphereDist * sphereDist ));
collision.rayDistance = sphereProj - ( offset );
collision.collidedAt = r.GetPosition () + ( r.GetDirection () * collision.rayDistance );
return true ;
}
这是对该理论的一个非常直接的实现——我们计算出光线与球体中心之间的方向向量(第 5 行),然后使用点积运算符将这个向量投影到光线的方向向量上(第 7 行)——这让我们看到在我们“连接”到另一个向量的末端之前,我们可以沿着方向向量走多远(第 12 行)。如果那个投影点大于球体的半径,光线就不可能发生碰撞(第 14 行),否则,我们通过沿着方向向量将碰撞点向后移动来确定碰撞点,以便它接触到球体的表面,而不是在球体内部。第 9 行涵盖了球体在光线后面的情况——在这种情况下,光线方向与光线和物体之间的方向向量的点积最终会是负数,我们不应再进一步考虑该物体。
射线/AABB相交代码
接下来,我们将看一下光线与盒子的碰撞。首先,我们要编写一个函数来执行盒子和光线之间的相交检测。将以下代码添加到 CollisionDetection 类文件中的 RayBoxIntersection 函数中:
bool RayBoxIntersection ( const Ray &r , const Vector3 & boxPos ,const Vector3 & boxSize , RayCollision & collision ) {
Vector3 boxMin = boxPos - boxSize;
Vector3 boxMax = boxPos + boxSize;
Vector3 rayPos = r.GetPosition();
Vector3 rayDir = r.GetDirection();
Vector3 tVals (-1 , -1 , -1);
for (int i = 0; i < 3; ++ i) { // get best 3 intersections
if (rayDir [i] > 0) {
tVals [i] = ( boxMin [i] - rayPos [i]) / rayDir [i];
}
else if( rayDir [i] < 0) {
tVals [i] = ( boxMax [i] - rayPos [i]) / rayDir [i];
}
}
float bestT = tVals.GetMaxElement();
if(bestT < 0.0 f) {
return false ; // no backwards rays !
}
为了进行盒子检测,我们将使用“简化”的情况,即只检查最接近的三个盒子平面,而不是全部六个平面。为了做到这一点,我们要检查光线的方向——如果光线向左,我们只检查盒子的右侧;如果光线向上,我们只检查盒子的底部;如果光线向前,我们只检查盒子的背面。由于盒子是轴对齐的,我们只需要检查光线方向的每个单独轴,以及盒子在该轴上的最小或最大范围——这就是为什么在第 12 行和第 15 行我们对照[i]进行检查,这将给我们一个向量的 x、y 或 z 轴。沿着向量的结果长度然后存储在另一个 Vector3 中,即 tVals。这允许我们使用向量的 GetMaxElement 成员方法,正如其名称所示,它将给我们具有最大幅度的浮点数。我们用负值初始化 tVals 向量——如果在 for 循环结束后其中的最大元素仍然是负的,那么交点实际上在光线后面,应该被忽略(通过第 20 行的返回)。从最大值中,我们可以确定沿着光线向量的最佳交点,并将其存储在 intersection 变量中,并确定它实际上是否在盒子的表面上:
Vector3 intersection = rayPos + ( rayDir * bestT );
const float epsilon = 0.0001 f; // an amount of leeway in our calcs
for (int i = 0; i < 3; ++ i ) {
if (intersection [i] + epsilon < boxMin [i] ||
intersection [i] - epsilon > boxMax [i]) {
return false ; // best intersection doesn ’t touch the box !
}
}
collision.collidedAt = intersection;
collision.rayDistance = bestT;
return true ;
}
for 循环只是遍历每个轴,并确定交点是否在盒子的某一侧太远,这是由盒子在该轴上的最小和最大范围决定的。请注意,我们使用一个轻微的误差范围(称为epsilon)来适应浮点精度的轻微变化——如果一个点距离一个立方体只有 0.0001 个单位,并且这个距离仅仅是由于浮点数的运算方式造成的,我们不希望这个点被算作“不相交”。如果它确实发生了碰撞,我们可以直接填写我们的碰撞细节,并返回 true。
一旦我们有了 RayBoxIntersection 函数,我们就可以将它用于 AABB 和 OBB 的光线碰撞。AABB 碰撞是通过 RayAABBIntersection 函数计算的,这个函数几乎只是一个“传递”到我们刚刚编写的函数,因为它所需要做的就是从 AABB 中获取盒子的大小和位置,并将它们用作参数:
bool CollisionDetection::RayAABBIntersection (const Ray &r,
const Transform & worldTransform ,
const AABBVolume & volume , RayCollision & collision ) {
Vector3 boxPos = worldTransform.GetPosition();
Vector3 boxSize = volume.GetHalfDimensions();
return RayBoxIntersection(r , boxPos , boxSize , collision);
}
对于有向包围盒(OBB)来说,事情就有点棘手了,因为我们需要变换光线,使其处于盒子的局部空间中,并且如果发生碰撞,将碰撞点变换回世界空间中。为了将光线带入盒子的局部空间,我们减去盒子的位置,并通过盒子的方向四元数的共轭(比反转矩阵更好!)形成的盒子的逆方向变换新形成的相对位置,以及光线的方向,从而给我们一个在有向包围盒的参考系中定义的新临时光线。然后,我们可以对位于原点的“轴对齐包围盒”(盒子位于它自己的原点位置)进行光线投射,如果发生碰撞,我们通过对碰撞点执行相反的操作将碰撞点带回到世界空间中(我们把位置加回来,然后通过世界变换旋转它)。
bool CollisionDetection::RayOBBIntersection(const Ray &r ,
const Transform & worldTransform ,
const OBBVolume & volume , RayCollision & collision ) {
Quaternion orientation = worldTransform.GetOrientation();
Vector3 position = worldTransform.GetPosition();
Matrix3 transform = Matrix3 (orientation);
Matrix3 invTransform = Matrix3 (orientation . Conjugate());
Vector3 localRayPos = r . GetPosition() - position ;
Ray tempRay(invTransform * localRayPos , invTransform * r . GetDirection());
bool collided = RayBoxIntersection(tempRay , Vector3() ,volume . GetHalfDimensions() , collision );
if (collided) {
collision . collidedAt = transform * collision . collidedAt + position ;
}
return collided ;
}
总结
虽然示例代码看起来有点简单,但我们通过制作它学到了很多。首先,我们已经看到了如何在世界中点击物体——没有多少游戏是你不点击某个东西或者至少确定十字准线或鼠标指针下是什么的,所以这本身就很重要。其次,我们已经看到了这个相同的机制如何让程序员确定一个物体是否能看到另一个物体。人工智能经常使用这样的测试来模拟视觉,以确定去哪里或攻击什么,所以当我们后面进入人工智能算法时,这会很有用。最后,我们也学习了一些基本碰撞形状背后的基本原理,并看到了与光线计算相交并不太耗费计算资源。随着我们进入碰撞检测和碰撞解决算法,我们将更频繁地使用这些形状。
在接下来的两个教程中,我们将看看如何在我们的物体中模拟物理上准确的线性和角运动,以便它们能够以现实的方式移动,并开始构建我们物理引擎的实际物理部分。不过,我们将使用光线投射来推动物体,所以从相机形成光线并选择物体是非常有用的。