HomeArchiveFeedShelf

Godot 中的动态 3D 物理绳索 - Part1

用 Blender 创建一条不错的静态物理绳索并不难。不过在某些场景下,我们必须在运行时创建物理绳索。本文尝试解决下面这个问题:

给定两个 PhysicsBody3D 和绳索长度(长度总是大于两个物体之间的距离),在运行时用一条会响应物理系统的绳索将它们连接起来。

去年我在一个已经放弃的 3D 太空狼人杀游戏项目里解决了这个问题。现在我想分享一下我是怎么做的。

物理绳索基础

与流体物质模拟类似,一条连续的物理绳索通常会被分成离散的部分。

每个圆都是一个 JointPin3D。图中的矩形表示 segment,不过这里不会真正绘制出来,因为 segment 在这里其实只是一个抽象概念(渲染会在 Part 2 里讨论)。每个 joint 连接两个相邻的 joint 或物理体。通过使用 Joint,我们可以得到一条拥有 6 个自由度的物理绳索。

Tips: 要让 Joint 工作,我们至少需要一个 RigidBody3D

输入

为了在运行时创建绳索,我们接收 4 个输入。

  1. resolution: int,segment 数量。这个值越高,离散模拟看起来就越连续。
  2. rope_length: float,绳索有多长。当然,这必须大于两个锚点之间的距离。
  3. start_point: Node3D,一个 Node3D,提供绳索起始锚点的全局位置信息,它的父节点是物理体。
  4. end_point: Node3D,同上,但提供结束锚点的信息。

概览

基本上我们只需要两个步骤:

  1. 沿着绳索正确放置 segment。我们可以很容易地根据 rope_lengthresolution 计算 segment length,这里的 resolution 也就是 joint 数量。
  2. 在每两个 segment 之间的间隙放置一个 joint,用它连接相邻的两个 segment。

第 2 步很简单,我们需要多讨论一下第 1 步。

安排 Segment 的位置

在创建 segment 之前,我们怎么知道它们的位置?

我们需要在两个锚点之间画一条线,然后根据 segment_lengthjoint_length 将 segment 均匀地安排在这条线上。其中 segment_length 可以很小,比如 0.1(Pinjoint3D 不管初始距离是多少都可以正常工作),joint_length = (rope_length - resolution * segment_length) / joint_countjoint_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 的轨迹是一个圆,它由两个半径分别为 r1r2 的球体相交得到。

我们已经知道:

  1. 两个球体的球心,称为 c1c2
  2. 相交圆的法线,也就是 c1c2 之间的向量。
  3. r1r2,我们可以近似认为它们几乎都是 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

下一篇文章中,我们会讨论渲染部分。

Reference

https://www.zhihu.com/question/39049685

https://blog.sina.com.cn/s/blog_6496e38e0102vi7e.html

@2023-09-06 23:51