title: 抛射体运动在游戏开发中的实践
date: 2022/04/07
前言
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 - 位移(Displacement)
-
x - 水平位移(Horizontal Displacement)
-
y - 垂直位移(Vertical Displacement)
-
h - 最大高度(Max Height)
-
θ - 初始角度(Initial Angle)
-
v - 初始速度(Initial Velocity)
-
t - 时间(Time)
-
g - 重力加速度(Gravitational Acceleration)
注:我们默认认为初始速度 v 是有方向的,对应的是初始角度 θ 。
注:标准重力加速度 g 的值为 9.80665,但在游戏引擎中一般默认为 9.8 或 10。在 Cocos Creator 3.4.2 中重力加速度的默认值为 10。
注:本文所讨论的抛射体运动均为「理想的抛射体运动」。
自由落体位移公式
当物体只受重力影响时的位移计算公式。
水平位移公式
在抛射体运动中,物体在水平方向上只受初速度影响。
也就是说物体水平速度恒等于初速度的水平分量,即 v * \cos{θ} 。
注:在水平位移的计算中我们以“右”为正方向。
垂直位移公式
在抛射体运动中,物体在垂直方向上受初速度和重力加速度影响。
也就是说,物体其实同时存在有两种垂直方向位移:
-
受初速度垂直分量影响的位移
-
受重力影响的自由落体位移
注:我们在垂直位移的计算中以“上”为正方向,所以自由落体位移是负值。
高级模式
欢迎来到主线任务!
固定角度
现在来实现第二种模式:固定一个发射角度,根据目标位置动态计算炮弹的初始速度。
在该模式下,大炮的发射角度固定,给出一个三维空间中的目标点,我们需要计算出炮弹发射的初速度。
计算水平和垂直位移
有了目标点的位置,并且我们本就知道发射点的位置,那我们就可以计算出从发射点到目标点的水平位移和垂直位移。
将两个点都投影到同一水平面上后,两个点的距离就是水平距离;而发射点和目标点之间的 y 值之差就是它们的垂直距离。
实际的计算过程如下:
-
使用矢量减法得到从发射点到目标点的方向矢量
-
方向矢量的 y 值就是垂直距离
-
将方向矢量投影到水平面上后,其长度就是水平距离
注:在游戏开发中,想要表示水平面,最简单的方式就是使用一个方向垂直向上的方向矢量作为平面法线,比如一个值为 { x: 0, y: 1, z: 0 }
的方向矢量。
注:在 Cocos Creator 3.4.2 中 Vec3.UP
= new Vec3(0, 1, 0)
。
初始速度公式
咳咳,回到正题。
来看下现在我们拥有的信息:
-
水平位移( x )
-
垂直位移( y )
-
初始角度( θ )
而我们想要求的是:
- 初始速度( v )
再看一眼上文中的「水平位移公式」和「垂直位移公式」,我们似乎还缺少一个关键的信息:
- 时间( t )
问题不大!我们可以试着消除掉这个时间项( t )。
首先,我们可以轻易地从「水平位移公式」得到「水平位移时间公式」:
然后将这个「水平位移时间公式」代入「垂直位移公式」:
好家伙,成功消除掉时间项( t )了,得到了一个新的垂直位移公式,为了区分我们把它命名为「垂直位移公式 2」吧。
最后我们再试着“孤立”公式中的初始速度项( v ):
Boom!
没错!这就是我们想要的初始速度公式!
代码实践
现在,我们把公式变成代码:
注:对于某些无法计算的情况,代码中增加了有效性的判断。
大炮在瞄准时根据目标位置来计算初始速度:
阶段性成果:
固定速度
第三种模式:固定炮弹的初始速度,根据目标位置动态计算发射的角度。
在该模式下,大炮发射炮弹的初速度固定,给出一个三维空间中的目标点,我们需要计算出发射的角度。
初始速度公式
在「速度公式」的推导中,我们得到了一个无时间项( t )的「垂直位移公式 2」,在这里我们可以试着直接用这条公式来推导角度公式。
慢着,现在我们要求的是初始角度( θ ),而在「垂直位移公式 2」中同时存在 \tan{θ} 和 \cos^2{θ} ,这可不是什么好事。
但好消息是,我们可以直接将 \cos^2{θ} 替换成 \frac{1}{\tan^2{θ} + 1} (因为它们是相等的),这应该能够帮助我们减少一些困难。
新的公式看上去似乎变得更复杂了,我们先试着推推看:
这里可以把等号左边的项移到右边看看:
咦,式子好像符合「一元二次方程(Quadratic Equation)」的特征,稍微整理一下:
嘿嘿嘿,这不就是个一元二次方程嘛!
我们可以把公式的几个部分都一一对应上:
这下好办了,对于一元二次方程,我们可以直接用「公式法」来求解。
这是一元二次方程的「求根公式」:
将未知数项、二次项系数、一次项系数和常数项都代入求根公式:
Bingo!
终于得到「初始角度公式」啦!
不过还有一点需要注意,这个公式最多拥有两个解。
一元二次方程(Quadratic Equation)
只含有一个未知数(一元),并且未知数项的最高次数是 2(二次)的整式方程叫做一元二次方程。
一元二次方程经过整理都可化成一般形式 ax^2 + bx + c = 0 (a ≠ 0) 。其中 ax^2 叫作二次项, a 是二次项系数; bx 叫作一次项, b 是一次项系数; c 叫作常数项。
—— 简介摘自百度百科
代码实践
把公式变成代码:
注:对于某些无法计算的情况,代码中增加了有效性的判断。
大炮在瞄准时根据目标位置来计算初始角度。
对于求出两个结果的情况,我们直接选用较大的角度就好了。你也可以根据具体的情况来选用不同的角度。
如果没能求出有效的结果,那就说明无论如何使用什么角度发射炮弹,炮弹都无法到达目的位置。在这种情况下,我们可以选择维持当前的角度。
阶段性成果:
不固定角度和速度
前面我们分别实现了固定角度和固定速度的方案,都挺好的,就是感觉不太灵活。
怎么才算灵活呢?我不想把大炮固定在某个角度或速度,我想要自由,我什么都不想管!
那我们这就来实现第四种模式:指定一个目标位置,动态计算发射的角度和炮弹的初始速度。
情况分析
我们拥有的信息:
-
水平位移( x )
-
垂直位移( y )
我们要求的是:
-
初始角度( θ )
-
初始速度( v )
还记得一开始的「垂直位移公式」吗,对,就是有时间项( t )的那个。
好家伙,一条式子总共 5 个项,其中 3 个未知,这搞毛…
看来还得另辟蹊径。
最大高度公式
我们都知道斜抛运动主要可以分为两个阶段,先是上升阶段,在达到一个最大高度后,随后就是下降阶段。
如果说我们可以把最大高度确定下来,是不是有一定的操作空间?
那物体在什么时候到达最大高度呢?
答案是当物体不再继续上升的时候(上升阶段结束,下降阶段开始之前),也就是说,此时此刻物体的垂直速度为 0。
我们在前面有提到过,在斜抛运动中,垂直位移受到两个速度的影响:
-
初始速度的垂直分量(垂直向上)
-
重力赋予的速度(垂直向下)
当物体的垂直速度为 0 时,意味着「初始速度的垂直分量」与「重力赋予的速度」相等:
从上面这个式子可以得到当物体达到最大高度时的时间公式,我们把它叫作「最大高度时间公式」:
我们把「最大高度时间公式」代入「垂直位移公式」,再次消除掉时间项( t ):
就这样我们得到了「最大高度公式」。
初始角度公式 2
根据「最大高度公式」,我们可以得到含最大高度项( h )和初始速度项( v )的「初始角度公式 2」:
初始速度公式 2
以及含最大高度项( h )和初始角度项( θ )的「初始速度公式 2」:
芜湖,买一赠二,非常的划算啊!
先别高兴,我们的问题还是没有解决…
从已有的公式来看,初始角度和初始速度总得先求出其中一个才能求出另一个,这不就尬住了嘛。
时间公式
正处一筹莫展之际,我们的老朋友「垂直位移公式」突然造访:
它说:这种情况下我们可以试着消项,尽量减少公式中的未知项,就和玩消消乐一样。
我说:好!有谁不喜欢玩玩消消乐呢!
这就把刚刚得到的「初始速度公式 2」代入「垂直位移公式」:
这时我们发现,这个公式似乎也满足「一元二次方程」的特征:
还记得吗,一元二次方程的一般形式:
把公式的几个部分都一一对应上:
还是一元二次方程的求根公式:
将未知数项、二次项系数、一次项系数和常数项都代入求根公式:
Great!
现在我们可以求出物体从出发点到目标点的总时间( t )了。
初始角度公式 3
情况有变,整理一下现在我们拥有的信息:
-
水平位移( x )
-
垂直位移( y )
-
最大高度( h )
-
时间( t )
我们要求的依旧是:
-
初始角度( θ )
-
初始速度( v )
故技重施,消消乐!
我们这次把「水平位移公式」请来:
把「初始速度公式 2」代进去试试:
Excellent!
这下我们能够凭借最大高度( h )和时间( t )求出初始角度( θ )了!
关键的公式
呼呼,这么多公式真是令人头大,我们先把有用的公式单独拿出来。
用「时间公式」来计算时间( t ):
用「初始角度公式 3」来计算初始角度( θ ):
用「初始速度公式 2」来计算初始速度( v ):
代码实践
数学真是“迷”人。
终于可以写代码了。
大炮在瞄准时根据目标位置来计算初始角度和初始速度。
在 calculateAngleAndVelocity
函数中我们将最大高度定为 1
。
先来看看实际的效果:
针不戳,这大概就是我们要的效果!
不过似乎还是有点问题,当我们把目标位置定在橙色柱子上方时,也就是目标点和发射点的垂直距离大于我们给出的最大高度时,大炮直接瓦特了。
是这样的,我们定义的最大高度是基于发射点的,当最大高度小于目标点和发射点的垂直距离时,炮弹肯定无法到达目标点。
另外,最大高度也绝对不能小于 0,因为发射点的高度就是 0,最大高度怎么能够比发射点的高度还小呢,这怎么也说不过去对吧。
所以,最大高度最好不要固定死,我们既然能够直接求出发射点的目标点垂直距离和水平距离,为什么不根据这两项数据来计算出一个合适的最大高度呢?
再来试试看:
Perfect!
至此我们已经成功实现了大炮的各种射击功能啦!
炮弹轨迹
还没结束呢!
当我们知道炮弹的发射点、目标点、初始角度和初始速度这些信息时,还可以将炮弹的运动轨迹绘制出来。
就像这样:
是不是很棒!
轨迹点生成
相对于前面的内容来说,这个实现起来非常简单,直接上公式:
没错,还是它们。
根据「水平位移公式」和「垂直位移公式」,我们可以计算出炮弹的运动过程中任意时刻的水平和垂直位移。
根据「水平位移时间公式」我们可以计算炮弹从发射点到目标点的总时间。
我们生成若干个轨迹点,并根据运动的总时间来计算轨迹点之间的时间间隔,遍历所有轨迹点,计算每个轨迹点对应时刻的位移,最后设置轨迹点的位置。
我这里使用了一个个小小的白色长方体来作为轨迹点,开启了批处理后,绘制整条轨迹只需要 1 个 Drawcall,基本不用担心性能问题。
看看效果:
可以看到生成的轨迹基本正确,但是还有两个问题要解决。
轨迹点数量
第一个问题是,轨迹点的数量固定,目标点离发射点越远,轨迹点之间的间隔也越远,显得很稀疏,不太美观。
对于这个问题,我们其实可以根据目标点和发射点的水平距离来决定轨迹点的数量。
效果:
对于轨迹点数量不固定且变化频繁的情况,建议使用节点池(Node Pool)来优化运行时性能,避免频繁地创建和销毁大量的节点。
轨迹点旋转
另一个问题是,每个轨迹点的位置虽然正确,但是它们的旋转没有发生变化,看起来很怪。
实际上,在抛射体运动中的每一个时刻,物体的运动方向都在变化。所以我们希望的是,每个轨迹点的朝向也应该和其对应时刻的运动方向相对应。
我们在开头有提到,在抛射体运动中物体的水平速度恒等于初速度的水平分量,可以得到公式:
而物体的垂直速度等于初速度的垂直分量(垂直向上)减去重力赋予的速度(垂直向下),可以得到公式:
有了水平和垂直两个方向的速度分量之后,就可以求出实际速度的方向了:
把公式变成代码:
Good,现在我们可以计算任意时刻的运动角度了,那我们要怎么将这个角度应用到三维空间的物体上呢?
在三维空间中,对于不同的轴之间的旋转,顺序非常重要,不同的顺序会得到截然不同的结果。
在我们这个例子中,我们需要先将轨迹点“朝向”目标(偏航角),然后再应用瞬时速度方向(俯仰角)。
在游戏开发中我们可以借助四元数(Quaternion)来实现多次旋转:
-
基于向前矢量和向上矢量生成四元数
-
基于 x 轴旋转来应用俯仰角
实际的代码:
最后的效果:
Wonderful!
瞄准光标
最后,我们给光标追加一个小 Feature。
加个需求
本文在「点击交互与射线投射」小节中简单介绍了如何通过射线投射来获取屏幕点击的三维世界坐标。
而射线投射返回的结果中除了击中点的坐标之外,还包括击中面的法线。
我们可以使用该法线来作为光标的正上方向,使光标能够始终垂直于击中点所在的表面。
适应目标表面
但是仅凭一条法线可没法确定物体的旋转啊!
一个三维的直角坐标系有 3 根互相垂直的轴矢量,至少需要两个互相垂直的矢量才能构建一个三维直角坐标系(可以通过矢量叉乘求出第三根轴)。
所以我们还需要找到一个垂直于目标面法线的矢量,而理论上一个矢量有无数个垂直于它的矢量,这可咋整?
就在我埋头干饭的时候,突发奇想,想到了一个非常取巧的方案:
-
使用目标位置和大炮位置生成一个方向矢量
-
将方向矢量投影到目标面法线所表示的平面上
-
归一化后得到一个垂直于目标面法线的单位矢量
-
通过该单位矢量和目标面法线就可以确定物体的旋转了
赶紧用代码实现出来:
看看效果:
我们的光标会始终垂直于物体的表面,并且光标的正前方会尽可能地朝着大炮的位置。
将十字准星换成圆圈之后更是毫无违和感:
啧啧啧,我可真是个小机灵鬼~
总结
不知道该怎么总结,没看明白的话,就再看一遍吧~
阿巴阿巴!
参考资料
Projectile motion:https://en.wikipedia.org/wiki/Projectile_motion
Quadratic equation:https://en.wikipedia.org/wiki/Quadratic_equation
传送门
公众号
菜鸟小栈
我是陈皮皮,一个还在不断学习的游戏开发者,一个热爱分享的 Cocos Star Writer。
这是我的个人公众号,专注但不仅限于游戏开发和前端技术分享。
每一篇原创都非常用心,你的关注就是我原创的动力!
Input and output.