大家好,欢迎来到IT知识分享网。
原文地址
一、box2d基础知识
1、关于
Box2D 是一个用于游戏的 2D 刚体仿真库。从游戏的视角来看,物理引擎就是一个程序性动画(procedural animation)的系统,而不是由动画师去移动你的物体。
1、核心概念
形状(shape) :一个2D的几何对象,例如圆或多边形。
刚体(rigid body) :一块十分坚硬的物质,它上面的任何两点之间的距离都是完全不变的。它们就像钻石那样坚硬。在后面的讨论中,我们用物体(body)来代替刚体。
固定装置(fixture):fixture绑定一个形状到物体,增加材料属性,例如密度,摩擦,恢复。注:也有翻译为”夹具”,”定制器”的
约束(constraint):一个约束(constraint)就是消除物体自由度的物理连接。在 2D 中,一个物体有 3 个自由度。如果我们把一个物体钉在墙上(像摆锤那样),那我们就把它约束到了墙上。这样,此物体就只能绕着这个钉子旋转,所以这个约束消除了它 2 个自由度。
接触约束(contact constraint):一个防止刚体穿透,以及用于模拟摩擦(friction)和恢复(restitution)的特殊约束。你永远都不必创建一个接触约束,它们会自动被 Box2D 创建。
关节(joint):它是一种用于把两个或多个物体固定到一起的约束。Box2D 支持的关节类型有:旋转,棱柱,距离等等。关节可以支持限制(limits)和马达(motors)。
关节限制(joint limit):一个关节限制限定了一个关节的运动范围。例如人类的胳膊肘只能做某一范围角度的运动。
关节马达(joint motor):一个关节马达能依照关节的自由度来驱动所连接的物体。例如,你可以使用一个马达来驱动一个肘的旋转。
世界(world):一个物理世界就是物体,形状和约束相互作用的集合。Box2D 支持创建多个世界,但这通常是不必要的。
解决器(solver):物理世界有一个解决器用来推进时间(advance time)和解决接触和关节约束。Box2D解决器是一个高效的迭代的解决器,它运行N次,N是约束的数目。
连续冲突(continuous collision):解决器用分离的时间步推进物体。没有它的介入,就会导致穿过(tunneling)。
Box2D包含专门的算法用来处理穿过。首先,collision算法为了找到第一时间的影响(TOI)可以篡改两个物体的移动。其次,sub-stepping解决器,它移动物体到他第一时间影响,然后解决collision。
模块
Box2D由三个模块组成:Common,Collision和Dynamics。Common模块包含分配,数学和设置(settings)。Collision模块定义形状,broad-phase和collision函数/资料库。最后Dynamics模块提供仿真模拟世界,物体,固定装置(fixtures)和关节。
3、 创建一个世界
每个 Box2D 程序都将从一个世界对象(world object)的创建开始。这是一个管理内存,对象和模拟的中心。
worldAABB.lowerBound.Set(-
100.0f, –
100.0f);
worldAABB.upperBound.Set(
100.0f,
100.0f);
0.0f, –
10.0f);
bool doSleep =
true;
//
当动态物体静止时使它休眠,减少性能开销
现在我们创建世界对象。
b2World world(worldAABB, gravity, doSleep);//在栈上创建world
4、创建一个地面
第一步,我们创建地面体。要创建它我们需要一个物体定义(body definition),通过物体定义我们来指定地面体的初始位置。
groundBodyDef.position.Set(
0.0f, –
10.0f);
第二步,将物体定义传给世界对象来创建地面体。世界对象并不保存到物体定义的引用。地面体是作为静态物体(static body)创建的,静态物体之间并没有碰撞,它们是固定的。当一个物体具有零质量的时候 Box2D 就会确定它为静态物体,物体的默认质量是零,所以它们默认就是静态的。
groundShapeDef.SetAsBox(
50.0f,
10.0f);
在第四步中,我们在地面体上创建地面多边形,以完成地面体。
//
创建形状用于碰撞检测等
bodyDef.position.Set(
0.0f,
4.0f);
b2Body* body = world.CreateBody(&bodyDef);
接下来我们创建并添加一个多边形形状到物体上。注意我们把密度设置为 1,默认的密度是 0。并且,形状的摩擦设置到了 0.3。形状添加好以后,我们就使用 SetMassFromShapes 方法来命令物体通过形状去计算其自身的质量。这暗示了你可以给单个物体添加一个以上的形状。如果质量计算结果为 0,那么物体会变成真正的静态。
b2PolygonDef shapeDef;
shapeDef.SetAsBox(1.0f, 1.0f);
shapeDef.density = 1.0f;
shapeDef.friction = 0.3f;
body->CreateShape(&shapeDef);
body->SetMassFromShapes();
6、模拟(Box2D 的)世界
我们已经初始化好了地面盒和一个动态盒。现在我们只有少数几个问题需要考虑。Box2D 中有一些数学代码构成的积分器(integrator),积分器在离散的时间点上模拟物理方程,它将与游戏动画循环一同运行。所以我们需要为 Box2D 选取一个时间步,通常来说游戏物理引擎需要至少 60Hz 的速度,也就是 1/60 的时间步。你可以使用更大的时间步,但是你必须更加小心地为你的世界调整定义。我们也不喜欢时间步变化得太大,所以不要把时间步关联到帧频(除非你真的必须这样做)。直截了当地,这个就是时间步:float32 timeStep = 1.0f / 60.0f;
除了积分器之外,Box2D 中还有约束求解器(constraint solver)。约束求解器用于解决模拟中的所有约束,一次一个。单个的约束会被完美的求解,然而当我们求解一个约束的时候,我们就会稍微耽误另一个。要得到良好的解,我们需要迭代所有约束多次。建议的 Box2D 迭代次数是 10 次。你可以按自己的喜好去调整这个数,但要记得它是速度与质量之间的平衡。更少的迭代会增加性能并降低精度,同样地,更多的迭代会减少性能但提高模拟质量。这是我们选择的迭代次数:
int32 iterations = 10;//一个时间步遍历10次约束
现在我们可以开始模拟循环了,在游戏中模拟循环应该并入游戏循环。每次循环你都应该调用 b2World::Step,通常调用一次就够了,这取决于帧频以及物理时间步。
这就是模拟 1 秒钟内 60 个时间步的循环
0; i <
60; ++i)
{
world.Step(timeStep, iterations);
}
7、API 设计
Box2D 使用浮点数,所以必须使用一些公差来保证它正常工作。这些公差已经被调谐得适合米-千克-秒(MKS)单位。尤其是,Box2D 被调谐得能良好地处理 0.1 到 10 米之间的移动物体。这意味着从罐头盒到公共汽车大小的对象都能良好地工作。
用户数据
b2BodyDef bodyDef;
bodyDef.userData = actor;
actor->body = box2Dworld->CreateBody(&bodyDef);
8、世界
b2World 类包含着物体和关节。它管理着模拟的方方面面,并允许异步查询(就像 AABB 查询)。你与 Box2D 的大部分交互都将通过 b2World 对象来完成。
要创建或摧毁一个世界你需要使用 new 和 delete:
new b2World(aabb, gravity, doSleep);
//
… do stuff …
delete myWorld;
世界类用于驱动模拟。你需要指定一个时间步和一个迭代次数。例如:
1.0f /
60.f;
int32 iterationCount =
10;
myWorld->Step(timeStep, iterationCount);
在时间步完成之后,你可以调查物体和关节的信息。最可能的情况是你会获取物体的位置,这样你才能更新你的角色并渲染它们。你可以在游戏循环的任何地方执行时间步,但你应该意识到事情发生的顺序。例如,如果你想要在一帧中得到新物体的碰撞结果,你必须在时间步之前创建物体。推荐使用固定的时间步。使用大一些的时间步你可以在低帧率的情况下提升性能。1/60 的时间步通常会呈现一个高质量的模拟。
扫描世界:
{
b->WakeUp();
}
AABB 查询:
aabb.minVertex.Set(-
1.0f, –
1.0f);
aabb.maxVertex.Set(
1.0f,
1.0f);
const int32 k_bufferSize =
10;
b2Shape *buffer[k_bufferSize];
int32 count = myWorld->Query(aabb, buffer, k_bufferSize);
for (int32 i =
0; i < count; ++i)
{
buffer[i]->GetBody()->WakeUp();
}
9、 物体
物体具有位置和速度。你可以应用力,扭矩和冲量到物体。物体可以是静态的或动态的,静态物体永远不会移动,并且不会与其它静态物体发生碰撞。物体是形状的主干,物体携带形状在世界中运动。在 Box2D 中物体总是刚体,这意味着同一刚体上的两个形状永远不会相对移动。通常你会保存所有你所创建的物体的指针,这样你就能查询物体的位置,并在图形实体中更新它的位置。另外在不需要它们的时候你也需要通过它们的指针摧毁它们。
质量性质:
1)在物体定义中显式地设置
bodyDef.massData.mass = 2.0f;//物体的质量是2kg
2)显式地在物体上设置(在其创建之后)
3)基于物体上的形状来进行密度设置
b2PolygonDef shapeDef;
1.0f,
1.0f);
shapeDef.density =
1.0f;
body->CreateShape(&shapeDef);
body->SetMassFromShapes();//这个函数成本较高,所以你应该只在需要时使用它。
const b2MassData* massData);
const;
float32 GetInertia()
const;
const b2Vec2& GetLocalCenter()
const;
位置和角度:
0.0f,
2.0f);
//
the body’s origin position.
bodyDef.angle =
0.25f * b2_pi;
//
the body’s angle in radians.
const b2Vec2& position, float32 angle);
const b2XForm& GetXForm()
const;
const b2Vec2& GetPosition()
const;
float32 GetAngle()
const;
你可以访问线速度与角速度,线速度是对于质心所言的。
const b2Vec2& v);
b2Vec2 GetLinearVelocity()
const;
void SetAngularVelocity(float32 omega);
float32 GetAngularVelocity()
const;
阻尼:
0.0f;
bodyDef.angularDamping =
0.01f;
休眠参数:
true;
bodyDef.isSleeping =
false;
子弹:
true;
隧道效应(tunneling)。默认情况下,Box2D 会通过
连续碰撞检测(CCD)来防止动态物体穿越静态物体,这是通过从形状的旧位置到新位置的扫描来完成的。引擎会查找扫描中的新碰撞,并为这些碰撞计算碰撞时间(TOI)。物体会先被移动到它们的第一个 TOI,然后一直模拟到原时间步的结束。如果有必要这个步骤会重复执行。一般 CCD 不会应用于动态物体之间,这是为了保持性能。在一些游戏环境中你需要在动态物体上也使用 CCD,譬如,你可能想用一颗高速的子弹去射击薄壁。没有 CCD,子弹就可能会隧穿薄壁。 CCD 的成本是昂贵的,所以你可能不希望所有运动物体都成为子弹。所以 Box2D 默认只在动态物体和静态物体之间使用 CCD,这是防止物体逃脱游戏世界的一个有效方法。然而,可能你有一些高速移动的物体需要一直使用 CCD。
状态信息:
const;
void SetBullet(
bool flag);
bool IsStatic()
const;
bool IsDynamic()
const;
bool IsFrozen()
const;
bool IsSleeping()
const;
void AllowSleeping(
bool flag);
void WakeUp();
力和冲量:
const b2Vec2& force,
const b2Vec2& point);
void ApplyTorque(float32 torque);
void ApplyImpulse(
const b2Vec2& impulse,
const b2Vec2& point);
false)
{
myBody->ApplyForce(myForce, myPoint);
}
坐标转换:
const b2Vec2& localPoint);
b2Vec2 GetWorldVector(
const b2Vec2& localVector);
b2Vec2 GetLocalPoint(
const b2Vec2& worldPoint);
b2Vec2 GetLocalVector(
const b2Vec2& worldVector);
列表
{
MyShapeData* data = (MyShapeData*)s->GetUserData();
…
do something with data …
}
你也可以用类似的方法遍历物体的关节列表。
10、 形状
形状就是物体上的碰撞几何结构。另外形状也用于定义物体的质量。也就是说,你来指定密度,Box2D 可以帮你计算出质量。形状具有摩擦和恢复的性质。形状还可以携带筛选信息,使你可以防止某些游戏对象之间的碰撞。形状永远属于某物体,单个物体可以拥有多个形状。形状是抽象类,所以在 Box2D 中可以实现许多
形状定义 :
形状定义用于创建形状。通用的形状数据会保存在 b2ShapeDef 中,特殊的形状数据会保存在其派生类中。
1)摩擦和恢复
摩擦可以使对象逼真地沿其它对象滑动。Box2D 支持静摩擦和动摩擦,但使用相同的参数。摩擦参数经常会设置在 0 到 1 之间,0 意味着没有摩擦,1 会产生强摩擦。当计算两个形状之间的摩擦时,Box2D 必须联合两个形状的摩擦参数,这是通过以下公式完成的:
friction = sqrtf(shape1->friction * shape2->friction);
恢复可以使对象弹起,想象一下,在桌面上方丢下一个小球。恢复的值通常设置在 0 到 1 之间,0 的意思是小球不会弹起,这称为非弹性碰撞;1 的意思是小球的速度会得到精确的反射,这称为完全弹性碰撞。恢复是通过这样的公式计算的:
restitution = b2Max(shape1->restitution, shape2->restitution);
当一个形状发生多碰撞时,恢复会被近似地模拟。这是因为 Box2D 使用了迭代求解器.
2) 密度
3) 筛选
碰撞筛选是一个防止某些形状发生碰撞的系统。
种群,对于任何一个形状你都可以指定它属于哪个种群。你还可以指定这个形状可以和其它哪些种群发生碰撞。例如,你可以在一个多人游戏中指定玩家之间不会碰撞,怪物之间也不会碰撞,但是玩家和怪物会发生碰撞。这是通过掩码来完成的,例如:
0x0002;
monsterShapeDef.filter.categoryBits =
0x0004;
playerShape.filter.maskBits =
0x0004;
monsterShapeDef.filter.maskBits =
0x0002;
碰撞组可以让你指定一个整数的组索引。你可以让同一个组的所有形状总是相互碰撞(正索引)或永远不碰撞(负索引)。组索引通常用于一些以某种方式关联的事物,就像自行车的那些部件。在下面的例子中,shape1 和 shape2 总是碰撞,而 shape3 和 shape4 永远不会碰撞。
2;
shape2Def.filter.groupIndex =
2;
shape3Def.filter.groupIndex = –
8;
shape4Def.filter.groupIndex = –
8;
不同组索引之间形状的碰撞会按照种群和掩码来筛选。换句话说,组筛选比种群筛选有更高的优选权。
4)传感器
有时候游戏逻辑需要判断两个形状是否相交,但却不应该有碰撞反应。这可以通过传感器(sensor)来完成。传感器会侦测碰撞而不产生碰撞反应。你可以将任一形状标记为传感器,传感器可以是静态或动态的。记得,每个物体上可以有多个形状,并且传感器和实体形状是可以混合的。
myShapeDef.isSensor = true;
5) 圆形定义
def.radius =
1.5f;
def.localPosition.Set(
1.0f,
0.0f);
6)多边形定义
这里是一个三角形的多边形定义的例子:
triangleDef.vertexCount =
3;
triangleDef.vertices[
0].Set(-
1.0f,
0.0f);
triangleDef.vertices[
1].Set(
1.0f,
0.0f);
triangleDef.vertices[
2].Set(
0.0f,
2.0f);
7)形状工厂
初始化一个形状定义,而后将其传递给父物体;形状就是这样创建的。
circleDef.radius =
3.0f;
circleDef.density =
2.5f;
b2Shape* myShape = myBody->CreateShape(&circleDef);
11、关节
关节的作用是把物体约束到世界,或约束到其它物体上。在游戏中的典型例子是木偶,跷跷板和滑轮。关节可以用许多种不同的方法结合起来,创造出有趣的运动。
你可以为任何一种关节指定用户数据。你还可以提供一个标记,用于预防相连的物体发生碰撞。实际上,这是默认行为,你可以设置 collideConnected 布尔值来允许相连的物体碰撞。很多关节定义需要你提供一些几何数据。一个关节常常需要一个锚点(anchor point)来定义,这是固定于相接物体中的点。在 Box2D 中这点需要在局部坐标系中指定,这样,即便当前物体的变化违反了关节约束,关节还是可以被指定 —— 在游戏存取进度时这经常会发生。另外,有些关节定义需要默认的
2)距离关节
距离关节是最简单的关节之一,它描述了两个物体上的两个点之间的距离应该是常量。当你指定一个距离关节时,两个物体必须已在应有的位置上。随后,你指定两个世界坐标中的锚点。第一个锚点连接到物体 1,第二个锚点连接到物体 2。这些点隐含了距离约束的长度。
这是一个距离关节定义的例子。在此我们允许了碰撞。
jointDef.Initialize(myBody1, myBody2, worldAnchorOnBody1,
worldAnchorOnBody2);
jointDef.collideConnected =
true;
3)旋转关节
一个旋转关节会强制两个物体共享一个锚点,即所谓铰接点。旋转关节只有一个自由度:两个物体的相对旋转。这称之为关节角。
要指定一个旋转关节,你需要提供两个物体以及一个世界坐标的锚点。初始化函数会假定物体已经在应有位置了。在此例中,两个物体被旋转关节连接于第一个物体的质心。
jointDef.Initialize(myBody1, myBody2, myBody1->GetWorldCenter());
这里是对上面旋转关节定义的修订;这次,关节拥有一个限制以及一个马达,后者用于模拟摩擦。
jointDef.Initialize(body1, body2, myBody1->GetWorldCenter());//使用 Initialize() 创建关节时,旋转关节角为 0,无论两个物体当前的角度怎样。
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;
你可以访问旋转关节的角度,速度,以及扭矩。
const
;
float32 GetJointSpeed() const;
float32 GetMotorTorque() const;
你也可以在每步中更新马达参数。
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 …
4)移动关节
移动关节(prismatic joint)允许两个物体沿指定轴相对移动,它会阻止相对旋转。因此,移动关节只有一个自由度。
b2Vec2 worldAxis(1.0f, 0.0f);
jointDef.Initialize(myBody1, myBody2, myBody1->GetWorldCenter(),
worldAxis);
jointDef.lowerTranslation = –5.0f;
jointDef.upperTranslation = 2.5f;
jointDef.enableLimit = true;
jointDef.motorForce = 1.0f;
jointDef.motorSpeed = 0.0f;
jointDef.enableMotor = true;
旋转关节隐含着一个从屏幕射出的轴,而移动关节明确地需要一个平行于屏幕的轴。这个轴会固定于两个物体之上,沿着它们的运动方向。就像旋转关节一样,当使用 Initialize() 创建移动关节时,移动为 0。所以一定要确保移动限制范围内包含了 0。移动关节的用法类似于旋转关节,这是它的相关成员函数:
const;
float32 GetJointSpeed()
const;
float32 GetMotorForce()
const;
void SetMotorSpeed(float32 speed);
void SetMotorForce(float32 force);
5)滑轮关节
滑轮关节用于创建理想的滑轮,它将两个物体接地(ground)并连接到彼此。这样,当一个物体升起时,另一个物体就会下降。滑轮的绳子长度取决于初始时的状态。
你还可以提供一个系数(ratio)来模拟滑轮组,这会使滑轮一侧的运动比另一侧要快。同时,一侧的约束力也比另一侧要小。你也可以用这个来模拟机械杠杆(mechanical leverage)。length1 + ratio * length2 == constant 举个例子,如果系数是 2,那么 length1 的变化会是 length2 的两倍。另外连接 body1 的绳子的约束力将会是连接 body2 绳子的一半。当滑轮的一侧完全展开时,另一侧的绳子长度为零,这可能会出问题。此时,约束方程将变得奇异。因此,滑轮关节约束了每一侧的最大长度。另外出于游戏原因你可能也希望控制这个最大长度。最大长度能提高稳定性,以及提供更多的控制。
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);
jointDef.maxLength1 =
18.0f;
jointDef.maxLength2 =
20.0f;
滑轮关节提供了当前长度:
const;
float32 GetLength2()
const;
6) 齿轮关节
如果你想要创建复杂的机械装置,你可能需要齿轮。原则上,在 Box2D 中你可以用复杂的形状来模拟轮齿,但这并不十分高效,而且这样的工作可能有些乏味。另外,你还得小心地排列齿轮,保证轮齿能平稳地啮合。Box2D 提供了一个创建齿轮的更简单的方法:齿轮关节。
齿轮关节需要两个被旋转关节或移动关节接地(ground)的物体,你可以任意组合这些关节类型。另外,创建旋转或移动关节时,Box2D 需要地(ground)作为 body1。类似于滑轮的系数,你可以指定一个齿轮系数(ratio),齿轮系数可以为负。另外值得注意的是,当一个是旋转关节(有角度的)而另一个是移动关节(平移)时,齿轮系数是长度或长度分之一。coordinate1 + ratio * coordinate2 == constant这是一个齿轮关节的例子:
jointDef.body1 = myBody1;
jointDef.body2 = myBody2;
jointDef.joint1 = myRevoluteJoint;
jointDef.joint2 = myPrismaticJoint;
jointDef.ratio =
2.0f * b2_pi / myLength;
7)关节工厂
关节是通过世界的工厂方法来创建和摧毁的,这引出了一个旧问题:
jointDef.body1 = myBody1;
jointDef.body2 = myBody2;
jointDef.anchorPoint = myBody1->GetCenterPosition();
b2RevoluteJoint* joint = myWorld->CreateJoint(&jointDef);
//
… do stuff …
myWorld->DestroyJoint(joint);
joint = NULL;
8)使用关节
在许多模拟中,关节被创建之后便不再被访问了。然而,关节中包含着很多有用的数据,使你可以创建出丰富的模拟。首先,你可以在关节上得到物体,锚点,以及用户数据。
b2Body* GetBody2();
b2Vec2 GetAnchor1();
b2Vec2 GetAnchor2();
void* GetUserData();
11、接触
接触(contact)是由 Box2D 创建的用于管理形状间碰撞的对象。接触有不同的种类,它们都派生自 b2Contact,用于管理不同类型形状之间的接触。例如,有管理多边形之间碰撞的类,有管理圆形之间碰撞的类。
触点(contact point)
切向力(tangent force)
当两个形状的 AABB 重叠时,接触就被创建了。有时碰撞筛选会阻止接触的创建,有时尽管碰撞已筛选了 Box2D 还是须要创建一个接触,这种情况下它会使用 b2NullContact 来防止碰撞的发生。当 AABB 不再重叠之后接触会被摧毁。也许你会皱起眉头,为了没有发生实际碰撞的形状(只是它们的 AABB)却创建了接触。好吧,的确是这样的,这是一个“鸡或蛋”的问题。我们并不知道是否需要一个接触,除非我们创建一个接触去分析碰撞。如果形状之间没有发生碰撞,我们需要正确地删除接触,或者,我们可以一直等到 AABB 不再重叠。Box2D 选择了后面这个方法。
{
const b2FilterData& filter1 = shape1->GetFilterData();
const b2FilterData& filter2 = shape2->GetFilterData();
if (filter1.groupIndex == filter2.groupIndex && filter1.groupIndex !=
0)
{
return filter1.groupIndex >
0;
}
bool collide = (filter1.maskBits & filter2.categoryBits) !=
0 &&
(filter1.categoryBits & filter2.maskBits) !=
0;
return collide;
}
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://yundeesoft.com/116148.html