Godot 中的动态 3D 物理绳索 - Part1
用 Blender 创建一条不错的静态物理绳索并不难。不过在某些场景下,我们必须在运行时创建物理绳索。本文尝试解决下面这个问题:
给定两个
PhysicsBody3D和绳索长度(长度总是大于两个物体之间的距离),在运行时用一条会响应物理系统的绳索将它们连接起来。
去年我在一个已经放弃的 3D 太空狼人杀游戏项目里解决了这个问题。现在我想分享一下我是怎么做的。
物理绳索基础
与流体物质模拟类似,一条连续的物理绳索通常会被分成离散的部分。

每个圆都是一个 JointPin3D。图中的矩形表示 segment,不过这里不会真正绘制出来,因为 segment 在这里其实只是一个抽象概念(渲染会在 Part 2 里讨论)。每个 joint 连接两个相邻的 joint 或物理体。通过使用 Joint,我们可以得到一条拥有 6 个自由度的物理绳索。
Tips: 要让 Joint 工作,我们至少需要一个
RigidBody3D
输入
为了在运行时创建绳索,我们接收 4 个输入。
resolution: int,segment 数量。这个值越高,离散模拟看起来就越连续。rope_length: float,绳索有多长。当然,这必须大于两个锚点之间的距离。start_point: Node3D,一个 Node3D,提供绳索起始锚点的全局位置信息,它的父节点是物理体。end_point: Node3D,同上,但提供结束锚点的信息。
概览
基本上我们只需要两个步骤:
- 沿着绳索正确放置 segment。我们可以很容易地根据
rope_length和resolution计算 segment length,这里的resolution也就是 joint 数量。 - 在每两个 segment 之间的间隙放置一个 joint,用它连接相邻的两个 segment。
第 2 步很简单,我们需要多讨论一下第 1 步。
安排 Segment 的位置
在创建 segment 之前,我们怎么知道它们的位置?
我们需要在两个锚点之间画一条线,然后根据 segment_length 和 joint_length 将 segment 均匀地安排在这条线上。其中 segment_length 可以很小,比如 0.1(Pinjoint3D 不管初始距离是多少都可以正常工作),joint_length = (rope_length - resolution * segment_length) / joint_count,joint_count = resolution + 1。
我们把第一个和最后一个 joint 放在 start point 和 end point 的位置,这样模块使用者可以自行定制。
当 resolution 是 1 或 2 时,问题会简单得多。
当 resolution 为 1 时,只有一个 segment 连接两个 joint。
当 resolution 为 2 时,joint 数量为 3,其中 2 个在锚点位置,segment 数量为 2(与 resolution 相同)。为了让这 3 个 joint 均匀分布,只需要把中间那个放在中央。

当 resolution 大于 2 时,事情就有趣起来了。由于绳索长度大于距离,我们必须把这条线弯成两段,中间有一个转折点。我们把它称为点 T,并假设要连接物体 A 和物体 B。这条线被分为两部分,一部分从物体 A 到点 T,另一部分从点 T 到物体 B。接下来只需要沿着这两段均匀放置 pinjoint。

现在我们需要计算点 T 的全局位置。
假设 T 之前那段的长度为 r1,T 之后那段的长度为 r2。不难发现,T 的轨迹是一个圆,它由两个半径分别为 r1 和 r2 的球体相交得到。

我们已经知道:
- 两个球体的球心,称为
c1和c2。 - 相交圆的法线,也就是
c1和c2之间的向量。 r1和r2,我们可以近似认为它们几乎都是rope_length的一半。如果因为resolution的奇偶性导致r1不等于r2,也完全没问题。
借助参数方程,我们可以求解这个空间圆。
其中

下面是圆的代码:
class_name SphereSphereCircle
extends Object
var _rng = RandomNumberGenerator.new()
# spheres
var _sphere_a_center: Vector3
var _sphere_a_radius: float
var _sphere_b_center: Vector3
var _sphere_b_radius: float
var _sphere_sphere_distance: float
# triangle
var cosA: float
var a: float
var b: float
var c: float
# circle
var center: Vector3
var radius: float
var vector_a: Vector3
var vector_b: Vector3
var normal: Vector3
func _init(sphere_a_position: Node3D, sphere_a_radius: float, \
sphere_b_position: Node3D, sphere_b_radius: float):
_sphere_a_center = sphere_a_position.global_transform.origin
_sphere_a_radius = sphere_a_radius
_sphere_b_center = sphere_b_position.global_transform.origin
_sphere_b_radius = sphere_b_radius
_sphere_sphere_distance = _sphere_a_center.distance_to(_sphere_b_center)
_calc_center()
_calc_radius()
_calc_vectors()
func _calc_center():
a = _sphere_b_radius
b = _sphere_a_radius
c = _sphere_sphere_distance
cosA = (pow(b, 2) + pow(c, 2) - pow(a, 2)) / (2 * b * c)
var k := b * cosA / c
normal = _sphere_b_center - _sphere_a_center
center = _sphere_a_center + k * normal
func _calc_radius():
var sinA = sqrt(1 - pow(cosA, 2))
radius = b * sinA
func _calc_vectors():
var unit = Vector3.UP
if not unit.cross(normal) == Vector3.ZERO:
vector_a = unit.cross(normal).normalized()
else:
unit = Vector3.RIGHT
vector_a = unit.cross(normal).normalized()
vector_b = normal.cross(vector_a).normalized()
func get_point(theta: float) -> Vector3:
var point := center + radius * cos(theta) * vector_a + radius * sin(theta) * vector_b
return point
初始化这个圆,在 theta,然后调用 get_point(theta),就能得到点 T 的全局位置。
有了点 T 之后,剩下的工作就是沿着弯折后的线均匀放置 segment,并用 PinJoint3D 连接 segment 或物理体。
插入 PinJoint
现在我们已经有了由 RopeSegment 组成的 segments,其中每一个都具有正确的全局位置。接下来要做的就是用 PinJoint3D 连接它们。
func _add_joints():
var first_segment: RopeSegment = segments[0]
var last_segment: RopeSegment = segments[resolution - 1]
var first_joint = _generate_joint_between(head_anchor, first_segment)
var last_joint = _generate_joint_between(last_segment, tail_anchor, 0)
first_segment.add_child(first_joint)
first_joint.global_transform.origin = start_point.global_transform.origin
last_segment.add_child(last_joint)
last_joint.global_transform.origin = end_point.global_transform.origin
for i in len(segments):
if segments.size() >= i + 2:
var segment: RopeSegment = segments[i]
var next_segment: RopeSegment = segments[i + 1]
var joint = _generate_joint_between(segment, next_segment)
segment.add_child(joint)
var sphere_middle_point = segment.global_transform.origin + (next_segment.global_transform.origin - segment.global_transform.origin) * 0.5
joint.global_transform.origin = sphere_middle_point
而 _generate_joint_between() 只是设置约束,让游戏引擎的物理系统去求解。
func _generate_joint_between(body_a: PhysicsBody3D, body_b: PhysicsBody3D, priority: int = 1) -> PinJoint3D:
var joint := PinJoint3D.new()
joint.set_node_a(body_a.get_path())
joint.set_node_b(body_b.get_path())
joint.set_solver_priority(priority)
return joint
更多细节可以查看 GitHub repo。
在下一篇文章中,我们会讨论渲染部分。