Chapter 8 关节
8.1 关于
关节用于把物体约束到世界,或约束到其它物体上。在游戏中,典型例子有木偶,跷跷板和滑轮。用不同的方式将关节结合起来使用,可以创造出有趣的运动。
有些关节提供了限制(limit),使你可以控制运动的范围。有些关节还提供了马达(motor),它可以以指定的速度驱动关节一直运动,直到你指定了更大的力或扭矩来抵消这种运动。
关节马达有许多不同的用途。你可以使用关节来控制位置,只要提供一个与目标之距离成正比例的关节速度即可。你还可以模拟关节摩擦:将关节速度置零,并且提供一个小的、但有效的最大力或扭矩;那么马达就会努力保持关节不动,直到负载变得过大为止。
8.2 关节定义
每种关节类型都有各自的定义(definition),它们都派生自b2JointDef。所有的关节都连接两个不同的物体,其中一个物体有可能是静态的。关节也可以连接两个static或者kinematic类型的物体,但这没有任何实际用途,只会浪费处理器时间。
你可以为任何一种关节类型指定用户数据。你还可以提供一个标记,用于防止用关节相连的物体之间发生碰撞。实际上, 这是默认行为。你也可以通过设置 collideConnected,来允许相连的物体之间发生碰撞。
很多关节定义需要你提供一些几何数据。一个关节常常需要一个锚点(anchor point)来定义,这是固定于相接物体中的点。Box2D要求这些点在局部坐标系中指定,这样,即便当前物体的变化违反了关节约束(joint constraint),关节还是可以被指定——这通常会发生在游戏保存或载入进度时。另外,有些关节定义需要知道物体之间默认的相对角度。这样才能正确地约束旋转。
初始化几何数据可能有些乏味。所以很多关节提供了初始化函数,使用当前的物体的形状,来消除大部分工作。然而,这些初始化函数通常只应用于原型,在产品代码中应该直接地定义几何数据。这能使关节行为更具健壮性。
其余的关节定义数据依赖于关节的类型。下面我们来介绍它们。
8.3 关节工厂
关节使用world的工厂方法来创建和摧毁。这引入一个老问题:
注意
不要使用new或malloc在栈(stack)或堆(heap)中创建关节。你想创建或摧毁物体和关节,必须使用b2World类中对应的创建或摧毁函数。
这里有个例子,展示了旋转关节(revolute joint)的生命周期:
b2RevoluteJointDef jointDef;
jointDef.bodyA = myBodyA;
jointDef.bodyB = myBodyB;
jointDef.anchorPoint = myBodyA->GetCenterPosition();
b2RevoluteJoint* joint = (b2RevoluteJoint*)myWorld->CreateJoint(&jointDef);
... do stuff ...
myWorld->DestroyJoint(joint);
joint = NULL;
一个很好的习惯:当对象摧毁后,就将对应的指针清零。这样的话,当你试图再次使用这个指针时,程序会以一种可控的方式崩溃。
(译注:野指针虽然不一定会使程序崩溃,但它所带来的问题非常难以调试。空指针虽然一定会让程序崩溃,但是调试起来很简单。)
关节的生命周期并不简单,要特别留心下面的警告。
注意
物体被摧毁时,依附其上的关节也会被摧毁。
上面的注意并非时时必要。你可以组织好自己的游戏引擎,保证物体被摧毁前,依附其上的关节已经先被摧毁。在这种情况下,你没有必要实现监听类 (listener class),去监听物体被摧毁的事件。更多细节请看隐式摧毁(Implicit Destruction)那小节。
8.4 使用关节
在许多模拟中,关节被创建之后,直到摧毁也不会再被访问。然而,关节中包含着很多有用的数据,使你可以创建出丰富的模拟。
首先,你可以在关节上得到物体、锚点和用户数据。
b2Body* GetBodyA();
b2Body* GetBodyB();
b2Vec2 GetAnchorA();
b2Vec2 GetAnchorB();
void* GetUserData();
所有的关节都有反作用力和反扭矩,这个反作用力应用于 body2 的锚点之上。你可以用反作用力来折断关节(break joints),或引发其它游戏事件。这些函数可能需要做一些计算,所以没有必要就不要去调用它们。
b2Vec2 GetReactionForce();
float32 GetReactionTorque();
8.5 距离关节
距离关节是最简单的关节之一, 它是说, 两个物体上面各自有一点,两点之间的距离必须固定不变。当你指定一个距离关节时,两个物体必须已在应有的位置上。之后,你指定世界坐标中的两个锚点。第一个锚点连接 到物体1,第二个锚点连接到物体2。这两点隐含了距离约束的长度。
这是一个距离关节定义的例子。这种情况下, 我们允许物体碰撞。
b2DistanceJointDef jointDef;
jointDef.Initialize(myBodyA, myBodyB, worldAnchorOnBodyA, worldAnchorOnBodyB);
jointDef.collideConnected = true;
距离关节也可以是软的,就像用橡皮筋来连接。看看testbed中的Web例子,就可以知道它有什么样的行为。
要使关节有弹性,可以调节一下定义中的两个常数:频率(frequency)和阻尼率(damping ratio)。将频率想象成谐振子(harmonic oscillator,比如吉他弦)振动的快慢。频率使用单位赫兹(Hertz)来指定。典型情况下,关节频率要小于时间步(time step)频率的一半。比如每秒执行60次时间步, 距离关节的频率就要小于30赫兹。这样做的理由可以参考Nyquist频率理论。
阻尼率无单位,典型是在0到1之间,也可以更大。1是阻尼率的临界值,当阻尼率为1时,没有振动。
jointDef.frequencyHz = 4.0f;
jointDef.dampingRatio = 0.5f;
8.6 旋转关节
旋转关节会强制两个物体共享一个锚点,即所谓铰接点。旋转关节只有一个自由度:两个物体的相对旋转。这称之为关节角。
要指定一个旋转关节,你需要提供两个物体以及世界坐标的一个锚点。初始化函数会假定物体已经在正确的位置了。
在此例中,两个物体被旋转关节连接起来,其中铰接点为第一个物体的质心。
b2RevoluteJointDef jointDef;
jointDef.Initialize(myBodyA, myBodyB, myBodyA->GetWorldCenter());
在 body2 逆时针旋转时,关节角为正。像所有 Box2D 中的角度一样,旋转角也是弧度制的。按规定,使用Initialize() 创建关节时,无论两个物体当前的角度怎样,旋转关节角都为0。
有时候,你可能需要控制关节角。为此,旋转关节可以随意地模拟关节限制和马达。
关节限制(joint limit)会强制关节角度保持在一定范围内,它会应用足够的扭矩来保持这个范围。0应该在范围内,否则在开始模拟时关节会有点倾斜。
关节马达允许你指定关节的角速度(角度的时间导数),速度可正可负。马达可以有产生无限大的力,但这通常是没有必要的。想想那个经典问题:
"当一个不可抵抗的力作用在一个不可移动的物体上,会发生什么?"
我可以告诉你这并不有趣。所以你应该为关节马达提供一个最大扭矩。关节马达会维持在指定的速度,除非其所需的扭矩超出了最大扭矩。当超出最大扭矩时,关节会慢下来,甚至会反向运动。
你还可以使用关节马达来模拟关节摩擦。只要把关节速度设为0,并将最大扭矩设为很小但有效的值。这样马达会试图阻止关节旋转,除非有过大的负载。
这里是对上面旋转关节定义的修订; 这次,关节拥有一个限制以及一个马达,后者用于模拟摩擦。
b2RevoluteJointDef jointDef;
jointDef.Initialize(bodyA, bodyB, myBodyA->GetWorldCenter());
jointDef.lowerAngle = -0.5f * b2_pi; // -90 degrees
jointDef.upperAngle = 0.25f * b2_pi; // 45 degrees
jointDef.enableLimit = true;
jointDef.maxMotorTorque = 10.0f;
jointDef.motorSpeed = 0.0f;
jointDef.enableMotor = true;
你可以访问旋转关节的角度,速度和马达扭矩。
float32 GetJointAngle() const;
float32 GetJointSpeed() const;
float32 GetMotorTorque() const;
每次执行step后,你也可以更新马达的参数。
void SetMotorSpeed(float32 speed);
void SetMaxMotorTorque(float32 torque);
关节马达有些有趣的功能。你可以在每个时间步中更新关节的速度,使得它像正弦波或者任意一个你想要的函数那样前后摆动。
... Game Loop Begin ...
myJoint->SetMotorSpeed(cosf(0.5f * time));
... Game Loop End ...
你也可以用关节马达来跟踪你想要的关节角。比如:
... Game Loop Begin ...
float32 angleError = myJoint->GetJointAngle() - angleTarget;
float32 gain = 0.1f;
myJoint->SetMotorSpeed(-gain * angleError);
... Game Loop End ...
通常你的增益参数不应太大,不然关节会变得不稳定。
8.7 移动关节
移动关节(prismatic joint)允许两个物体沿指定轴相对移动,它会阻止相对旋转。因此,移动关节只有一个自由度。
移动关节的定义有些类似于旋转关节;只是转动角度换成了平移,扭矩换成了力。以这样的类比,我们来看一个带有关节限制以及马达摩擦的移动关节定义:
b2PrismaticJointDef jointDef;
b2Vec2 worldAxis(1.0f, 0.0f);
jointDef.Initialize(myBodyA, myBodyB, myBodyA->GetWorldCenter(), worldAxis);
jointDef.lowerTranslation = -5.0f;
jointDef.upperTranslation = 2.5f;
jointDef.enableLimit = true;
jointDef.maxMotorForce = 1.0f;
jointDef.motorSpeed = 0.0f;
jointDef.enableMotor = true;
旋转关节隐含着一个从屏幕射出的轴,而移动关节明确地需要一个平行于屏幕的轴。这个轴会固定于两个物体之上,沿着它们的运动方向。
就像旋转关节一样,当使用 Initialize() 创建移动关节时,移动为0。所以要确保0在你的移动限制范围内。
移动关节的用法跟旋转关节类似。这是相应的成员函数:
float32 GetJointTranslation() const;
float32 GetJointSpeed() const;
float32 GetMotorForce() const;
void SetMotorSpeed(float32 speed);
void SetMotorForce(float32 force);
8.8 滑轮关节
滑轮关节用于创建理想的滑轮,它将两个物体接地(ground)并彼此连接。这样,当一个物体上升,另一个物体就会下降。滑轮的绳子长度取决于初始配置。
length1 + length2 == constant
你还可以提供一个系数(ratio)来模拟滑轮组,这会使滑轮一侧的运动比另一侧要快。同时,一侧的约束力也比另一侧要小。你也可以用这个来创建机械杠杆。
length1 + ratio * length2 == constant
举个例子,如果系数是2,那么 length1 的变化会是 length2 的两倍。另外连接body1的绳子的约束力将会是连接body2绳子的一半。
滑轮的一侧完全展开时,另一侧的绳子长度为零,这可能会出问题。此时,约束方程将变得奇异(糟糕)。你应该配置碰撞形状以避免这种情况。
这是一个滑轮定义的例子:
b2Vec2 anchor1 = myBody1->GetWorldCenter();
b2Vec2 anchor2 = myBody2->GetWorldCenter();
b2Vec2 groundAnchor1(p1.x, p1.y + 10.0f);
b2Vec2 groundAnchor2(p2.x, p2.y + 12.0f);
float32 ratio = 1.0f;
b2PulleyJointDef jointDef;
jointDef.Initialize(myBody1, myBody2, groundAnchor1, groundAnchor2, anchor1, anchor2, ratio);
滑轮关节提供函数得到当前长度。
float32 GetLengthA() const;
float32 GetLengthB() const;
8.9 齿轮关节
如果你想创建复杂的机械装置,可能需要齿轮。原则上,在 Box2D 中你可以用复杂的形状来模拟轮齿,但这并不十分高效,而且可能有些乏味。另外,你还得小心地排列齿轮,保证轮齿能平稳地啮合。Box2D 提供了一个创建齿轮的更简单的方法:齿轮关节。
齿轮关节只能连接旋转关节和移动关节。
类似于滑轮系数,你可以指定一个齿轮系数(ratio),齿轮系数可以为负。另外值得注意的是,当一个是旋转关节(有角度的)而另一个是移动关节(平移)时,齿轮系数有长度单位,或者是长度单位的倒数。
coordinate1 + ratio * coordinate2 == constant
这是一个齿轮关节的例子。物体myBodyA和myBodyB来自于两个关节,并且它们不是同一个物体。
b2GearJointDef jointDef;
jointDef.bodyA = myBodyA;
jointDef.bodyB = myBodyB;
jointDef.joint1 = myRevoluteJoint;
jointDef.joint2 = myPrismaticJoint;
jointDef.ratio = 2.0f * b2_pi / myLength;
注意,齿轮关节依赖于两个其它关节,这是脆弱的:当其它关节被删除了会发生什么?
注意
齿轮关节总应该先于旋转或移动关节被删除。否则你的代码将会因访问齿轮关节的孤儿关节指针而导致崩溃。另外齿轮关节也应该在任何相关物体被删除之前删除。
8.10 鼠标关节
在testbed例子中,鼠标关节用于通过鼠标来操控物体。它试图将物体拖向当前鼠标光标的位置。而在旋转方面就没有限制。
鼠标关节的定义需要一个目标点(target point),最大力(maximum force),频率(frequency),阻尼率(damping ratio)。目标点最开始与物体的锚点重合。最大力用于防止在多个动态物体相互作用时的激烈反应。你想将最大力设为多大就多大。频率和阻尼率用于创造一种弹性效果,就跟距离关节类似。
许多用户为了游戏的可玩性,会试图修改鼠标关节。用户常常希望鼠标关节有即时反应,精确的去到某个点。这情况下,鼠标关节表现并不好。你可以考虑一下用kinematic物体来替代。
8.11 轮子关节
轮子关节限制bodyB上的一个点到bodyA的一条线上。轮子关节也提供悬置弹簧的效果。细节参见b2WheelJoint.h和Car.h。
8.12 焊接关节
焊接关节的用途是使两个物体不能相对运动。看看testbed中的Cantilever例子,可以知道焊接关节有怎么样的表现。
用焊接关节来定义一个可分裂物体,这想法很诱人。但是,由于Box2D的迭代求解,关节焊得有点不稳。因此用焊接关节连接起来的物体会有所摆动。
创建可裂物体的更好方法是使用单个的物体,上面有很多fixture。 当物体分裂时,你可以删掉原物体的一个fixture,并重新在一个新的物体上创建它。参考一下testbed中的Breakable例子。
8.13 绳子关节
绳子关机限制了两个点之间的最大距离。它能够阻止连接的物体之间的拉伸,即使在很大的负载下。细节参见b2RopeJoint.h和RopeJoint.h。
8.14 摩擦关节
摩擦关节被用于模拟上下摩擦。关节提供2D的传统摩擦和角度摩擦。细节参见b2FrictionJoint.h 和ApplyForce.h。
8.15 马达关节
马达关节通过指定目标位置和旋转偏移来控制物体的移动。你能设置最大的马达力和力矩来到达目标位置和旋转。如果物体被阻塞,它将停下来,接触力与最大的马达力和力矩成正比。细节参见b2MotorJoint和MotorJoint.h。