【Unity】重力场中的路径预测方法
前言
笔者前些天参加完了一场72小时的GameJam游戏开发比赛。这次比赛的主题是“探索”,笔者做了一个名为《探索者号》的探索宇宙的游戏(游戏名一开始叫做《星际拾荒者》,但这不重要)。
在开发过程中,笔者遇到了一些问题,特此做下记录和分享,希望对大家和今后的我有所帮助。
笔者本次的参赛作品,在实现路径预测可视化时使用了RK4方法,效果还不错:
【72小时极限游戏开发挑战赛】探索者号
《探索者号》核心玩法
- 玩家可以控制飞船加速和转向,并可以射击障碍物来保证自身不被撞毁,探索7颗星球。
- 玩家的每个操作,还有随着时间流逝,都会消耗燃料。
- 燃料耗尽后,玩家将无法操控飞船,但5秒后会消耗生命值来补充一定燃料。
- 玩家每接近一个星球,并使用引力弹弓离开时,会将燃料加满。
- 生命值耗尽,游戏失败。
简而言之,就是借助引力弹弓来补充燃料和加速,以到达更远距离,探索更多星球的目的。
重力场中路径预测可视化
为什么有这种需求?
该游戏的难点在于,玩家无法凭空推算出或感觉出在飞船靠近行星时,应该如何调整自身方向,才能保证不撞到星球上,并且完成有效的“引力弹弓”动作。
所以笔者希望在游戏中添加一条路径预测的引导线,有了这根线,将大大降低新人玩家的上手难度。
核心思路
笔者的方法是在飞船对象上附加一个LineRenderer组件,利用它来绘制飞船在未来时间点的预测路径。
具体实现方式:首先利用当前的飞船速度和所受的引力影响,计算出飞船在“下一瞬间”的预期位置,并将这个位置设置为LineRenderer的第一个节点。接着基于这个预测位置,再计算出飞船在“下下一瞬间”的位置,将其设置为LineRenderer的第二个节点。通过重复这一过程,我们能够逐步构建出一系列时间点上的飞船位置节点。
通过将这些节点相连,形成了一条连续的引导线,这条引导线基于飞船的初始速度向量、飞船与行星之间的引力互动、以及它们的相对位置关系。这样在单个渲染帧内,我们就能够预测并展示飞船在接下来一段时间内的运动轨迹。
这种可视化的路径预测不仅增强了游戏的互动性和玩家的体验,还提供了一个直观的方式来理解和预测物体在复杂重力场中的动态行为。通过这种方法,玩家可以更好地规划飞船的航线,避免撞到星球,优化飞行轨迹。
常规方法
高中物理,略。
计算过程:
对于每个时间点
i
:
预测位置:
S
i
=
S
i
−
1
+
U
i
t
+
1
2
a
i
t
2
更新加速度:
a
i
=
gravityStrength
r
i
2
更新速度:
V
i
=
U
i
−
1
+
a
i
t
(假设
G
×
M
为行星的重力强度:
g
r
a
v
i
t
y
S
t
r
e
n
g
t
h
)
\text{对于每个时间点 } i: \\ \text{ 预测位置: } S_i = S_{i-1} + U_it + \frac{1}{2}a_it^2 \\ \text{ 更新加速度: } a_i = \frac{\text{gravityStrength}}{r_i^2} \\ \text{ 更新速度: } V_i = U_{i-1} + a_it \\ \\ (假设G×M为行星的重力强度:gravityStrength)
对于每个时间点 i: 预测位置: Si=Si−1+Uit+21ait2 更新加速度: ai=ri2gravityStrength 更新速度: Vi=Ui−1+ait(假设G×M为行星的重力强度:gravityStrength)
核心代码:
// 目标行星Transform
public Transform targetPlanet;
// 行星重力强度
public float gravityStrength;
// 路径点数
public int pathResolution = 50;
// 预测路径总时长
public float pathPredictTime = 5f;
private LineRenderer lineRenderer;
void Start()
{
lineRenderer = gameObject.AddComponent<LineRenderer>();
lineRenderer.positionCount = pathResolution;
}
void Update()
{
// 其他运动逻辑
// 调用UpdatePath进行路径预测
UpdatePath(currentPos, currentVelocity, currentAcceleration);
}
// 更新路径预测
private void UpdatePath(Vector2 currentPos, Vector2 currentVelocity, Vector2 currentAcceleration)
{
// 每一步的时间间隔
float t = pathPredictTime / pathResolution;
for (int i = 0; i < pathResolution; i++)
{
// 使用基本运动方程预测位置
Vector2 predictedPos = currentPos + currentVelocity * t + 0.5f * currentAcceleration * t * t;
// 将计算的位置设置为轨迹的一部分
lineRenderer.SetPosition(i, predictedPos);
// 基于新的预测位置,计算下一点的重力加速度
Vector2 gravityDirection = (Vector2)targetPlanet.position - predictedPos;
currentAcceleration = gravityDirection.normalized * gravityStrength / gravityDirection.sqrMagnitude;
// 更新当前位置和速度
currentPos = predictedPos;
currentVelocity += currentAcceleration * t;
}
}
RK4方法
Runge-Kutta第四阶(RK4)算法,是一种用于求解常微分方程初值问题的数值方法。给定一个常微分方程
d
y
d
t
=
f
(
t
,
y
)
\frac{\mathrm{d} y}{\mathrm{d} t} = f(t,y)
dtdy=f(t,y),
及其初始条件
y
(
t
0
)
=
y
0
y(t_0)=y_0
y(t0)=y0,
RK4方法通过以下步骤来估计在处的值,其中
h
h
h是步长:
k
1
=
f
(
t
,
y
)
,
k
2
=
f
(
t
+
h
2
,
y
+
h
2
k
1
)
,
k
3
=
f
(
t
+
h
2
,
y
+
h
2
k
2
)
,
k
4
=
f
(
t
+
h
,
y
+
h
k
3
)
,
y
(
t
+
h
)
=
y
+
h
6
(
k
1
+
2
k
2
+
2
k
3
+
k
4
)
.
\begin{align*} k_1 &= f(t, y), \\ k_2 &= f\left(t + \frac{h}{2}, y + \frac{h}{2}k_1\right), \\ k_3 &= f\left(t + \frac{h}{2}, y + \frac{h}{2}k_2\right), \\ k_4 &= f(t + h, y + hk_3), \\ \\ y(t + h) &= y + \frac{h}{6}(k_1 + 2k_2 + 2k_3 + k_4). \end{align*}
k1k2k3k4y(t+h)=f(t,y),=f(t+2h,y+2hk1),=f(t+2h,y+2hk2),=f(t+h,y+hk3),=y+6h(k1+2k2+2k3+k4).
这个过程提供了一种高精度的方式来逼近常微分方程的解,通过将整个步长 h h h分为更小的部分并计算在这些部分上的斜率,然后将这些斜率的加权平均值用于最终的估计。
应用到游戏中:
△
t
=
T
n
k
1
v
=
v
k
1
a
=
a
(
p
)
k
2
v
=
v
+
k
1
a
⋅
Δ
t
2
k
2
a
=
a
(
p
+
k
1
v
⋅
Δ
t
2
)
k
3
v
=
v
+
k
2
a
⋅
Δ
t
2
k
3
a
=
a
(
p
+
k
2
v
⋅
Δ
t
2
)
k
4
v
=
v
+
k
3
a
⋅
Δ
t
k
4
a
=
a
(
p
+
k
3
v
⋅
Δ
t
)
v
new
=
v
+
(
k
1
a
+
2
k
2
a
+
2
k
3
a
+
k
4
a
)
⋅
Δ
t
6
p
new
=
p
+
(
k
1
v
+
2
k
2
v
+
2
k
3
v
+
k
4
v
)
⋅
Δ
t
6
a
(
p
)
=
g
⋅
r
2
∥
d
∥
2
其中:
初始位置
p
和速度
v
需要根据游戏中实际情况确定
△
t
:每一步的时间间隔
T
:总预测时间
n
:分辨率(对应
L
i
n
e
R
e
n
d
e
r
e
r
的节点数)
k
1...
k
4
:四组斜率
d
:物体到行星中心的向量
g
:模拟行星重力强度(相当于
G
M
)
r
:行星半径
∥
d
∥
2
:
d
的平方模长
a
:加速度
\begin{align*} \triangle t &= \frac{T}{n} \\ k1_v &= v \\ k1_a &= a(p) \\ k2_v &= v + k1_a \cdot \frac{\Delta t}{2} \\ k2_a &= a\left(p + k1_v \cdot \frac{\Delta t}{2}\right) \\ k3_v &= v + k2_a \cdot \frac{\Delta t}{2} \\ k3_a &= a\left(p + k2_v \cdot \frac{\Delta t}{2}\right) \\ k4_v &= v + k3_a \cdot \Delta t \\ k4_a &= a(p + k3_v \cdot \Delta t) \\ \\ v_{\text{new}} &= v + \frac{(k1_a + 2k2_a + 2k3_a + k4_a) \cdot \Delta t}{6} \\ p_{\text{new}} &= p + \frac{(k1_v + 2k2_v + 2k3_v + k4_v) \cdot \Delta t}{6} \\ \\ a(p) &= \frac{g \cdot r^2}{\|d\|^2} \\ 其中 :& \\ &初始位置p和速度v需要根据游戏中实际情况确定 \\ \triangle t&:每一步的时间间隔 \\ T &:总预测时间 \\ n &:分辨率(对应LineRenderer的节点数) \\ k1...k4 &:四组斜率 \\ d &:物体到行星中心的向量 \\ g &:模拟行星重力强度(相当于GM) \\ r &:行星半径 \\ \|d\|^2 &:d的平方模长 \\ a &:加速度 \end{align*}
△tk1vk1ak2vk2ak3vk3ak4vk4avnewpnewa(p)其中:△tTnk1...k4dgr∥d∥2a=nT=v=a(p)=v+k1a⋅2Δt=a(p+k1v⋅2Δt)=v+k2a⋅2Δt=a(p+k2v⋅2Δt)=v+k3a⋅Δt=a(p+k3v⋅Δt)=v+6(k1a+2k2a+2k3a+k4a)⋅Δt=p+6(k1v+2k2v+2k3v+k4v)⋅Δt=∥d∥2g⋅r2初始位置p和速度v需要根据游戏中实际情况确定:每一步的时间间隔:总预测时间:分辨率(对应LineRenderer的节点数):四组斜率:物体到行星中心的向量:模拟行星重力强度(相当于GM):行星半径:d的平方模长:加速度
核心代码:
using UnityEngine;
public class PathPrediction : MonoBehaviour
{
// 玩家的初始位置和速度
public Vector2 initialPosition;
public Vector2 initialVelocity;
// 表示重力场源的行星
public Transform planetTransform;
// 行星的重力强度
public float planetGravity;
// 行星的半径
public float planetRadius;
// 路径分辨率,即路径上的点数
public int pathResolution = 100;
// 预测路径的总时长
public float pathPredictTime = 5f;
private LineRenderer lineRenderer;
private void Start()
{
lineRenderer = GetComponent<LineRenderer>();
lineRenderer.positionCount = pathResolution;
UpdatePathWithRK4();
}
// 使用RK4算法更新路径
private void UpdatePathWithRK4()
{
Vector2 currentPos = initialPosition;
Vector2 currentVelocity = initialVelocity;
float deltaTime = pathPredictTime / pathResolution;
for (int i = 0; i < pathResolution; i++)
{
// RK4方法的四个步骤
Vector2 k1_vel = currentVelocity;
Vector2 k1_acc = CalculateAcceleration(currentPos);
Vector2 k2_vel = currentVelocity + k1_acc * (deltaTime / 2f);
Vector2 k2_acc = CalculateAcceleration(currentPos + k1_vel * (deltaTime / 2f));
Vector2 k3_vel = currentVelocity + k2_acc * (deltaTime / 2f);
Vector2 k3_acc = CalculateAcceleration(currentPos + k2_vel * (deltaTime / 2f));
Vector2 k4_vel = currentVelocity + k3_acc * deltaTime;
Vector2 k4_acc = CalculateAcceleration(currentPos + k3_vel * deltaTime);
// 使用四个斜率的加权平均值来更新速度和位置
currentVelocity += (k1_acc + 2f * (k2_acc + k3_acc) + k4_acc) * (deltaTime / 6f);
currentPos += (k1_vel + 2f * (k2_vel + k3_vel) + k4_vel) * (deltaTime / 6f);
// 更新LineRenderer以显示路径
lineRenderer.SetPosition(i, new Vector3(currentPos.x, currentPos.y, 0));
}
}
// 计算给定位置处的加速度,考虑重力场的影响
private Vector2 CalculateAcceleration(Vector2 position)
{
Vector2 gravityDirection = (Vector2)planetTransform.position - position;
// 使用万有引力公式计算加速度
return gravityDirection.normalized * (planetGravity * Mathf.Pow(planetRadius, 2) / gravityDirection.sqrMagnitude);
}
}
总结
简单方法
优点:
- 简单直观,适用于线性系统或短时间内预测。
- 计算速度快。
缺点:
- 对于非线性系统或需要长时间预测的情况,简单的逼近方法可能不够精确,尤其是在引力场强烈变化的区域。
RK4方法
优点:
- 精度高,适用于复杂的动态系统,特别是需要准确模拟物理行为的系统。
- 稳定性强,在处理较平滑的动力学问题,拥有较高的稳定性。
缺点:
- 与简单方法相比,RK4需要在每个时间步长中计算四次斜率,这增加了每个时间步的计算负担。
- 实现更复杂,不易理解,需要更多的编码工作和调试。
(本游戏由于场景简单,多在路径预测上多花些资源也不算过分,于是使用了RK4方法,效果如文章开头的视频中所示)
实际使用中,我们可以根据不同的场景,选择更加合适的方法。
大佬们如果有优化思路,或者更多实现方式,也请多多指点!
吉祥话
最后祝大家新年快乐,长命百岁!