前言
Hi,我是陈皮皮。
概述
主线任务
本文的主线任务(核心主题)是「抛射体运动(Projectile Motion)」。
抛射体运动常常出现在我们的日常生活中,例如篮球运动员投篮后篮球在空中的运动。
在各种电子游戏中也可以看到抛射体运动的身影,例如《坦克世界》中坦克射击后炮弹在空中的运动。
如你所见,本文的标题是《抛射体运动在游戏开发中的实践》,我们将一起推导抛射体运动的各种公式,并在游戏中应用这些公式,实现各种有趣的功能。
支线任务
除了主线任务,本文还包括一些支线任务:
- 3D 游戏中的对象交互与射线投射
- 三维空间中矢量的点乘与叉乘(线性代数)
- 三维空间中矢量在平面上的投影计算
- 三维空间中矢量的有向角计算
- 实时预绘制炮弹的运动轨迹
成果
效果展示
先给大家看下我们要实现的效果。
在线预览:https://app.chenpipi.cn/cocos-case-projectile/
动图:
示例项目
与本文一同出现的示例项目「炮弹投射(Projectile)」为开源项目。
为了避免文章又臭又长,项目中的一些功能特性没有在文章中介绍,同时我会对项目代码进行整合再插入文内,所以实际的项目结构与代码和文章中出现的会有些许差异。
下载源码:
- Gitee 仓库:https://gitee.com/ifaswind/cocos-case-projectile
- GitHub 仓库:https://github.com/ifaswind/cocos-case-projectile
- Cocos 商店:https://store.cocos.com/app/detail/3680
本项目使用的游戏引擎为 Cocos Creator 3.4.2。
正文
场景搭建
先来一段轻松愉快的搭积木体验。
环境
快速搭建一个简单的场景,放置一些五颜六色且高矮不一的障碍物(或者说是平台)。
记得给这些东西都加上合适的碰撞器(Collider),这样我们才能够通过射线和它们进行交互。
🤪 实际上调整各种形状的尺寸和颜色花了大半天时间…
大炮
使用内置的几个基本形状组装了一门大炮(Cannon),尽管它看起来像个手电筒(🔦),但是我还是愿意称它为大炮!
🤪 我真的尽力了,花了老半天时间呢,不过看久了其实还挺顺眼的…
简单介绍一下这门大炮的几个关键部件:
-
让炮身可以左右旋转的偏航轴(Yaw Axis)
-
让炮管可以上下调整的俯仰轴(Pitch Axis)
-
决定炮弹发射位置和方向的发射点(Fire Point)
炮弹
使用内置的球体制作一个简单的炮弹(Bullet),保存为单独的预制体(Prefab)。
并且给炮弹添加一个球体碰撞器(Sphere Collider)和刚体(RigidBody)组件,这样炮弹就能拥有物理特性了。
另外有一点要注意的是,刚体组件的**线性速度衰减(LinearDamping)**项默认值为 0.1,我们需要将其设为 0,否则炮弹在飞行的时候会自动减速。
基础功能
用代码实现一些基础的功能。
瞄准与射击
Cannon
创建一个名为 Cannon
的组件脚本,用来实现大炮的各种功能。
我们先来简单实现「瞄准(Aim)」和「射击(Shoot)」的功能:
目前大炮的瞄准函数内只实现了俯仰角的更新。
GameController
创建一个名为 GameController
的组件脚本,用于实现游戏的控制逻辑。
实现「点击鼠标发射炮弹」的逻辑:
🎯 运行效果:
左右朝向
目前这门大炮只能朝一个固定的方向射击,呆头呆脑的,不如我们根据点击的位置来控制大炮的左右朝向吧。
点击交互与射线投射
我们先来简单了解下如何通过点击屏幕来指定大炮的目标位置。
一般在 2D 游戏中,想要通过鼠标或触摸屏与二维世界中的物体进行交互,只需要通过一个屏幕上的二维坐标就可以判断是否“击中”物体。
而在 3D 游戏中,想要与三维世界中的物体进行交互,则需要通过「射线投射(Raycast)」的方式实现。
简单来说就是从摄像机(Camera)的近裁剪面(Near Plane)发射一条射线(Ray),射线的长度等于近裁剪面到远裁剪面(Far Plane)的距离,这条射线与场景中的物体进行相交运算,如果相交则算作击中物体。
大多游戏引擎都会在摄像机上提供「通过屏幕空间上的二维坐标创建射线」的功能。例如 Cocos Creator 的 Camera 组件就提供了 screenPointToRay
函数。
具体的点击功能实现起来非常简单:
需要注意的是,场景中的物体要有任意类型的碰撞器(Collider)才能够参与射线检测。
直接看向目标
现在我们已经可以得到一个目标位置了,但是在多数情况下,我们的目标位置与炮身不在同一水平面上,如果让炮身的节点直接看向目标位置,会得到一个不太正常的表现。
另外,我偷偷制作了一个简单的光标(白色箭头和十字准星)来突出当前的目标位置。
注:由于在 Cocos Creator 3.4.2 中,节点的正前方指向的是 -z 方向,所以现在是大炮的屁股看向目标节点。
偏航角
实际上我们只想改变炮身的左右朝向,这里所说的“左右朝向”其实有更专业的术语,称为“偏航(Yaw)”。
🛩 偏航(Yaw)
偏航描述的是物体基于的偏航轴(在游戏开发中通常为物体自身坐标系中的 y 轴)的运动,改变了它指向的方向,到其运动方向的左侧或右侧。
注:在旋转中,除了“偏航(Yaw)”,通常与之一同出现的还有“俯仰(Pitch)”和“翻滚(Roll)”,分别描述基于另外两个主轴的运动。
想让大炮朝向指定的位置,我们需要改变的正是炮身的偏航角,正确的做法是:
- 创建一个从炮身偏航轴节点位置指向目标位置的方向矢量
- 将该方向矢量投影到偏航轴节点所在的水平面上
- 通过「矢量点乘(Dot Product)」计算方向矢量与大炮的向前矢量之间的夹角
注:“偏航轴节点”大炮的子节点,同时它承载了炮身的所有节点,改变偏航轴节点的偏航角就可以改变炮身的朝向了。
“失去方向”
慢着,通过矢量点乘来计算夹角得到的值永远都在 0~180 度之间,也就是说点乘能告诉你两个矢量的最小夹角度数,无法告诉你一个矢量在另一个矢量的左侧还是右侧,因为对于点乘来说“左侧 45 度”和“右侧 45 度”都是“45 度”。
举个栗子,下图中两个紫色矢量与蓝色矢量的夹角都是 45 度,但是它们分别在蓝色矢量的两侧:
有向角
但是在游戏引擎中,物体的旋转有效度数范围是 0~360,或者说是 -180~180 度(我们都知道“左转 90 度”和“右转 -90 度”、“右转 270 度”所表达的意思是一样的)。
说到底,其实我们就是需要一个带正负符号的夹角,也就是「有向角(Directed Angle)」。
当我们提到“方向”时,线性代数学得溜的同学很快就能想到,我们可以通过「矢量叉乘(Cross Product)」来判断一个矢量在另一个矢量的左侧还是右侧。
另外,在三维空间中矢量的有向角计算还需要有一个参照平面才有意义。
具体的计算步骤:
- 将两个矢量投影到同一参照平面上
- 通过叉乘求出两个矢量的法矢量
- 通过该法矢量在参照平面法矢量上的投影长度得到方向
- 使用点乘计算两个矢量的夹角
- 对夹角应用方向(符号)
关于「将矢量投影到平面上」的函数:
注:在三维世界中,使用一个方向矢量来充当平面法线就能够表示一类朝向相同的平面,因为“平面法线”意味着该方向矢量一定垂直于其所表示的平面。
代码实践
解决了偏航角的问题后,现在就给大炮加上「朝向目标位置」的代码:
注:在 Cocos Creator 3.4.2 中节点的旋转符合右手法则,即逆时针方向为正方向。
然后在 GameController
中实现「移动鼠标控制大炮瞄准」的逻辑:
🎯 阶段性成果:
射击模式
现在我们的大炮已经可以跟着鼠标 360 度旋转和发射炮弹了,但是炮弹发射的俯仰角度和速度都是固定的,这看起来一点都不高级。
而我们期望是:给出一个目标位置,大炮能够自行计算发射角度和速度。
针对这个目标,我们可以设计 3 种不同的模式:
- 固定发射角度,动态计算炮弹的初始速度
- 固定炮弹的初始速度,动态计算发射角度
- 动态计算发射角度和炮弹的初始速度
再加上目前「固定发射角度和炮弹的初始速度」的模式,我们总共有 4 种模式。
接下来我们将一一实现它们。
固定角度和速度
由于新增了模式的概念,为了让后面的编码更方便更优雅,我们把目前的代码结构稍作调整。
在 Cannon
组件的 aim
函数中根据模式来计算炮弹的发射角度和速度:
抛射体运动
虽然前面阿巴阿巴了这么多,但是似乎都和我们的主题抛射体运动没啥关系。
简单介绍
我们先来说说什么是抛射体运动?
抛射体运动是以任意初速抛出的物体在地球重力作用下的运动。
抛射体运动可以分为平抛运动和斜抛运动:
-
平抛运动(Horizontal Projectile Motion):物体的出射方向与水平面的夹角为 0,所以物体只有水平方向上的初速度(也可以看作是垂直方向上的初速度为 0)。
-
斜抛运动(Oblique Projectile Motion):物体的出射方向与水平面的有一定的夹角,所以物体不但有水平方向上的初速度,也有垂直方向上的初速度。
一般情况下,当我们提到抛射体运动时,指的都是斜抛运动,因为斜抛运动“兼容”平抛运动~
另外,抛射体运动也常被称作「抛物运动」,我觉得抛物运动这名字更顺口一点。
基本公式
👾 欢迎来到主线前置任务!
在深入之前,让我们先来学习(复习)一些简单的前置知识。
首先,我们来认识一下将会在各种公式中出现的成员们:
- s s s - 位移(Displacement)
- x x x - 水平位移(Horizontal Displacement)
- y y y - 垂直位移(Vertical Displacement)
- h h h - 最大高度(Max Height)
- θ θ θ - 初始角度(Initial Angle)
- v v v - 初始速度(Initial Velocity)
- t t t - 时间(Time)
- g g g - 重力加速度(Gravitational Acceleration)
注:我们默认认为初始速度 v v v 是有方向的,对应的是初始角度 θ θ θ。
注:标准重力加速度 g g g 的值为 9.80665,但在游戏引擎中一般默认为 9.8 或 10。在 Cocos Creator 3.4.2 中重力加速度的默认值为 10。
注:本文所讨论的抛射体运动均为「理想的抛射体运动」。
自由落体位移公式
当物体只受重力影响时的位移计算公式。
s = 1 2 ∗ g ∗ t 2 (自由落体位移公式) s = \frac{1}{2} * g * t^2 \tag{自由落体位移公式} s=21∗g∗t2(自由落体位移公式)
水平位移公式
在抛射体运动中,物体在水平方向上只受初速度影响。
也就是说物体水平速度恒等于初速度的水平分量,即 v ∗ cos θ v * \cos{θ} v∗cosθ。
x = v ∗ cos θ ∗ t (水平位移公式) x = v * \cos{θ} * t \tag{水平位移公式} x=v∗cosθ∗t(水平位移公式)
注:在水平位移的计算中我们以“右”为正方向。
垂直位移公式
在抛射体运动中,物体在垂直方向上受初速度和重力加速度影响。
也就是说,物体其实同时存在有两种垂直方向位移:
- 受初速度垂直分量影响的位移
- 受重力影响的自由落体位移
y = v ∗ sin θ ∗ t − 1 2 ∗ g ∗ t 2 (垂直位移公式) y = v * \sin{θ} * t - \frac{1}{2} * g * t^2 \tag{垂直位移公式} y=v∗sinθ∗t−21∗g∗t2(垂直位移公式)
注:我们在垂直位移的计算中以“上”为正方向,所以自由落体位移是负值。
高级模式
😈 欢迎来到主线任务!
固定角度
现在来实现第二种模式:固定一个发射角度,根据目标位置动态计算炮弹的初始速度。
在该模式下,大炮的发射角度固定,给出一个三维空间中的目标点,我们需要计算出炮弹发射的初速度。
计算水平和垂直位移
有了目标点的位置,并且我们本就知道发射点的位置,那我们就可以计算出从发射点到目标点的水平位移和垂直位移。
将两个点都投影到同一水平面上后,两个点的距离就是水平距离;而发射点和目标点之间的 y 值之差就是它们的垂直距离。
实际的计算过程如下:
- 使用矢量减法得到从发射点到目标点的方向矢量
- 方向矢量的 y 值就是垂直距离
- 将方向矢量投影到水平面上后,其长度就是水平距离
注:在游戏开发中,想要表示水平面,最简单的方式就是使用一个方向垂直向上的方向矢量作为平面法线,比如一个值为 { x: 0, y: 1, z: 0 }
的方向矢量。
注:在 Cocos Creator 3.4.2 中 Vec3.UP
= new Vec3(0, 1, 0)
。
初始速度公式
咳咳,回到正题。
来看下现在我们拥有的信息:
- 水平位移( x x x)
- 垂直位移( y y y)
- 初始角度( θ θ θ)
而我们想要求的是:
- 初始速度( v v v)
再看一眼上文中的「水平位移公式」和「垂直位移公式」,我们似乎还缺少一个关键的信息:
- 时间( t t t)
问题不大!我们可以试着消除掉这个时间项( t t t)。
首先,我们可以轻易地从「水平位移公式」得到「水平位移时间公式」:
x = v ∗ cos θ ∗ t (水平位移公式) x = v * \cos{θ} * t \tag{水平位移公式} x=v∗cosθ∗t(水平位移公式)
↓ \downarrow ↓
t = x v ∗ cos θ (水平位移时间公式) t = \frac{x}{v * \cos{θ}} \tag{水平位移时间公式} t=v∗cosθx(水平位移时间公式)
然后将这个「水平位移时间公式」代入「垂直位移公式」:
y = v ∗ sin θ ∗ x v ∗ cos θ − 1 2 ∗ g ∗ ( x v ∗ cos θ ) 2 y = v * \sin{θ} * \frac{x}{v * \cos{θ}} - \frac{1}{2} * g * \left({\frac{x}{v * \cos{θ}}}\right)^2 y=v∗sinθ∗v∗cosθx−21∗g∗(v∗cosθx)2
⇓ \Downarrow ⇓
y = x ∗ v ∗ sin θ v ∗ cos θ − 1 2 ∗ g ∗ ( x v ∗ cos θ ) 2 y = \frac{x * v * \sin{θ}}{v * \cos{θ}} - \frac{1}{2} * g * \left({\frac{x}{v * \cos{θ}}}\right)^2 y=v∗cosθx∗v∗sinθ−21∗g∗(v∗cosθx)2
⇓ \Downarrow ⇓
y = x ∗ tan θ − 1 2 ∗ g ∗ x 2 v 2 ∗ cos 2 θ (垂直位移公式 2) y = x * \tan{θ} - \frac{1}{2} * g * \frac{x^2}{v^2 * \cos^2{θ}} \tag{垂直位移公式 2} y=x∗tanθ−21∗g∗v2∗cos2θx2(垂直位移公式 2)
好家伙,成功消除掉时间项( t t t)了,得到了一个新的垂直位移公式,为了区分我们把它命名为「垂直位移公式 2」吧。
最后我们再试着“孤立”公式中的初始速度项( v v v):
y = x ∗ tan θ − 1 2 ∗ g ∗ x 2 v 2 ∗ cos 2 θ (垂直位移公式 2) y = x * \tan{θ} - \frac{1}{2} * g * \frac{x^2}{v^2 * \cos^2{θ}} \tag{垂直位移公式 2} y=x∗tanθ−21∗g∗v2∗cos2θx2(垂直位移公式 2)
↓ \downarrow ↓
0 = x ∗ tan θ − g ∗ x 2 2 ∗ v 2 ∗ cos 2 θ − y 0 = x * \tan{θ} - \frac{g * x^2}{2 * v^2 * \cos^2{θ}} - y 0=x∗tanθ−2∗v2∗cos2θg∗x2−y
↓ \downarrow ↓
g ∗ x 2 2 ∗ v 2 ∗ cos 2 θ = x ∗ tan θ − y \frac{g * x^2}{2 * v^2 * \cos^2{θ}} = x * \tan{θ} - y 2∗v2∗cos2θg∗x2=x∗tanθ−y
↓ \downarrow ↓
1 2 ∗ v 2 ∗ cos 2 θ = x ∗ tan θ − y g ∗ x 2 \frac{1}{2 * v^2 * \cos^2{θ}} = \frac{x * \tan{θ} - y}{g * x^2} 2∗v2∗cos2θ1=g∗x2x