本文最后更新于:2 years ago

3. Particle Module

Particle Module应该是LiquidFun与Box2D的主要不同部分。它使用户能够创造、操控液体或软性(可变形)物体。它允许你创造、摧毁具有许多不同行为和性质的粒子,并且提供了控制它们的多种方式。这个模块允许你以离散方式/成组方式定义粒子,以帮助你有效地操控大量粒子。

3.1 粒子

粒子是圆形的,且是粒子系统中最小的单位物质。在默认设置下,一个粒子的行为像液体那样。当然,你可以通过设置behavioral flag来给粒子/粒子组赋予不同的行为(这在下面的Particle Behaviors一节中有详细介绍)。

b2Particle.h包含了指明behavior value的enum变量b2ParticleFlag。

3.2 粒子系统

粒子们生活的“世界”被称作粒子系统。一个粒子系统指代的是很多决定了粒子如何互相作用、如何与world作用的物理系数,如默认粒子半径、弹性(elasticity)、粘性(viscosity)。更为详细的信息可以在b2ParticleSystemDef这个结构体的定义中看到。(太长了,这里不摘录了)

你可以通过如下方式创造一个粒子系统:

const b2ParticleSystemDef particleSystemDef;
m_particleSystems[0] = m_world->CreateParticleSystem(&particleSystemDef);

你也可以创造不止一个粒子系统。

一般来说你都不需要改变particleSystemDef的默认值/创造多个粒子系统,但某些情况下你也可能发现这两个功能很管用。例如:将粒子分成多个粒子系统后可以提高模拟效率(只模拟可见系统,将其它系统放置在paused状态)。在Testbed中提供了一个两个系统分别影响一个刚体、彼此不互动的例子。

3.3 粒子组

你能设置的粒子组的性质其中有部分与离散粒子的性质相同:行为、位置、线速度、颜色。也有一些性质是粒子组专属的:转动角度、转动速度、力量。这在b2ParticleGroup.h里可以看到。

离散粒子 vs. 粒子组

除了一个例子以外,单个粒子和粒子组之间都没有显著的功能差别。这个例子就是刚体粒子:由于使粒子变刚性的内部算法的特性远古,刚体粒子必须以群体的方式定义。

粒子组也有如下几个好处:首先,可以自动创造/摧毁大量粒子;其次,可以方便的给它们赋予同样的性质。

3.4 在世界中进行时间推演(粒子迭代)

粒子处理器(solver)可以在一个time step中迭代多次。迭代多次将提高粒子模拟的稳定性和准确性。然而,迭代次数越多,所需的处理器周期也就越多。

这些周期的时间成本几乎是线性的:迭代次数翻倍将导致b2ParticleSystem::Solve的周期时间成本翻倍。

你应当使用b2World::Step里面的particleIterations参数来调整迭代次数,默认值是1。

你可以通过实验找到particleIterations的最佳值,可以通过b2CalculateParticleIterations或者b2World::CalculateReasonableParticleIterations来估计迭代次数的合理值,但这两个函数都相当简化,仅应当被视作估计的起点。

如果你的模拟看起来过于热情洋溢/你模拟中的粒子正在穿过contact,你应当适当增加迭代次数。

注意到:随着迭代次数的增加,在高度压缩的粒子上压力的作用也会愈发明显(也就是说,粒子变得越来越不可压缩了)。

注:为了模拟的稳定性、并且防止过度的相互渗透,粒子模拟为粒子指定了强制性的最大速度

particle_diameter / (particle_iterations * b2World::Steps' dt)

3.5 创造/摧毁单个粒子

创造离散粒子

b2ParticleDef pd;
pd.flags = b2_elasticParticle; pd.color.Set(0, 0, 255, 255); pd.Position.Set(i, 0);
int tempIndex = m_particleSystem->CreateParticle(pd);

粒子的列表是自密实的。因此,CreateParticle返回的指标在一个更低指标的粒子(组)被删除时就会失效。

摧毁粒子

摧毁时请使用如下无返回值的函数:

m_particleSystem->DestroyParticle(tempIndex);

粒子的生命周期

粒子除了被手动删除外,还可能由于既定的生命周期过期/由于年龄过大被删除。

如下语句将让系统根据年龄顺序追踪粒子以进行删除:

m_particleSystem->SetParticleDestructionByAge(true);

一个粒子由于年龄而死有两种原因:

(1)超过预设的生命周期,过期了。如下程序设置了粒子的生命周期(介于min和max之间的随机数),index制定了被赋予该生命周期的粒子数:

m_particleSystem->SetParticleLifetime(index, Random() * (k_particleLifetimeMax - k_particleLifetimeMin) + k_particleLifetimeMin);

(2)在程序中,用户预设了同一时间能存在的粒子数的最大值,并且告诉系统根据年龄追踪粒子,则粒子会通过追杀多余粒子的方式保持粒子总数恒定,这个顺序是通过年龄顺序决定的,老粒子会先被暗杀。通过如下语句设置粒子最大数:

m_particleSytem->SetMaxParticleCount(k_maxParticleCount);

这可以在Testbed的Faucet一例中看到。

3.6 困住的粒子

粒子如果在特定地方卡住,就会成为障碍,需要被清除/换位置。如果一个粒子在用户指定数目的粒子迭代过程中始终与两个以上的平面保持接触,就会被系统判定为卡住。一旦被判定卡住后,你可以通过实现你自己的函数来判断是不是真的卡住了,并且处理卡住的粒子。

既然函数是用户实现的,用户在决定如何认定粒子卡住上就有了自主选择权。例如,对于一个正在穿过管道的球来说,它可能会与多边的墙发生碰撞,但用户可以通过自己定义的函数认定这个球没有卡住(只要这个球在正常运动)。另一方面,你也可以使用这样的判定依据:即使粒子在运动,但只要它卡在一个特定的空间范围内,我就认定它被卡住了。

如下例子中,用户建立了一个sensor fixture的全局数组,覆盖了所有可能出现卡壳粒子的区域。用户在每一步都会通过传感器检查所有系统给出的可能卡住的粒子,将任何一个处在危险区域的粒子删除:

void DestroyStuckParticlesInSensors(const b2Fixture* const *sensors, int32 num)
{
    const int32 stuck = gParticleSyste->GetStuckCandidateCount();
    if(stuck > 0)
    {
        const int32 *candidates = gParticleSystem->GetStuckCandidates();
        const b2Vec2 *positions = gParticleSystem->GetPositionBuffer();
        for (int32 j = 0; j < num; ++j)
        {
            if(sensors[j]->TestPoint(position))
            {
                gParticleSystem->DestroyParticle(particle);
            }
        }
    }
}
//连续5次迭代以上同时接触多个面的粒子进入名单
gParticleSystem->SetStuckThreshold(5);

gWorld->Step(gTimeStep, gVelocityIterations, gPositionIterations);

//检查
DestroyStuckParticlesInSensors(gProblemAreaSensors, gNumSensors);

3.7 创建/删除粒子组

一个粒子组的生命开始于一个有形状的容器中。创建顺序如下:

(1)指定一个shape;

(2)创造一个b2ParticleGroupDef-struct对象;

(3)指定粒子的行为和属性;

(4)使用指定函数创造粒子组。

如下的程序段创造了五组颜色不同、盒子形状的粒子:

b2PolygonShape shape;
shape.SetAsBox(10, 5);

b2ParticleGroupDef pd;
pd.shape = shape;
pd.flags = b2_elasticParticle;
pd.angle = -0.5f;
pd.angularVelocity = 2.0f;
for(int32 i = 0; i < 5; i++)
{
    pd.position.Set(10 + 20 * i, 40);
    pd.color.Set(i * 255/ 5, 255 - i * 255/5, 128, 255);
    m_particleSystem->CreateParticleGroup(pd);
}

为了摧毁粒子组,触发如下函数:

DestroyParticles(bool callDestructionListener);

这样的话,当组中没有粒子时,粒子组就会自动被摧毁(若b2_particleGroupCanBeEmpty变量在组的flags中未经设置)。

下面的这个粒子将粒子系统中的所有粒子摧毁:

b2ParticleGroup* group = m_particleSystem->GetParticleGroupList();
while(group)
{
    m_particleSystem->SetGroupFlags(m_particleSystem->GetGroupFlags() & ~b2_particleGroupCanBeEmpty);
    group->DestroyParticles(false);
    group = group->GetNext();
}
///(?)不懂欸

3.8 粒子行为

3.8.1 对粒子组而言

使用b2ParticleGroupFlag这个enum设置,分以下两种:

(1)Solid

固体的粒子组将阻止其它物体嵌入它的内部。如果任何物体尝试穿透它,固体粒子组将把物体推回到自己的表面。

固体粒子组同样有很强的斥力,在你希望一个物体在表面上发生超常有弹力的碰撞时很有用(如壁球撞到墙上)。

pd.groupFlags = b2_solidParticleGroup;

(2) Rigid

刚体粒子组的形状不会发生改变,即使当与其它物体碰撞的时候也是如此。使用刚体粒子组与直接使用刚体相比有如下几个好处:

  • 可以只删除组的一部分:

    • 例如,一颗子弹打过后,将在盒型粒子组里产生一个洞;
  • 和其它组融合:

    • 例如,先创造三个圆形的粒子组,再把它们合并拼成一个雪人儿;

pd.groupFlags = b2_rigidParticleGroup;

3.8.2 对单个粒子而言:

使用b2ParticleFlag这个enum。注意到不同的粒子行为所需的时间成本不同。

(1)Elastic(弹性)

弹性粒子会变形,与刚体碰撞的时候会弹跳。

pd.flags = b2_elasticParticle;

在Testbed中的“Elastic Particles”中,绿圈圈和蓝盒子就是由弹性粒子组成的。

(2)Color-mixing(混色)

混色粒子会从与其碰撞的粒子中蘸取一部分颜色。假设两个碰撞粒子中只有一个是color-mixing的,那么另一个粒子会保持原色。

混色是这样计算的:

  • deltaColor = colorMixingStrength * (B's color - A's color);

  • A's color += deltaColor;

    B's color -= deltaColor;

注意到若第二步中结果出现负数,会被改换为绝对值;若结果>255,会被模掉255。

pd.flags = b2_colorMixingParticle;

在Testbed的surface tension这一demo中可以看到。

(3)Powder(粉末)

粉末粒子会产生分散效果,就像灰尘/沙子那样。这在sparky这个demo中可以看到。

pd.flags = b2_powderParticle;

(4)Spring(弹簧)

弹簧粒子们的行为就好像它们通过弹簧成对连接那样。每个粒子都与它在创造时离得最近的粒子相连,相连之后就不会再换搭档。将两个粒子分离的外力越大,外力撤销时它们互相碰撞的力就越大。不管粒子之间离得有多远,弹簧都不会断。

pd.flags = b2_springParticle;

这在elastic particles里面的红圈圈可以看到。

(5)Tensile(可伸长的)

可拉长粒子是用于创造表面张力/液体物体上拉紧后弯曲这一效果的。它可以用于创造,例如,水滴上的表面张力。

一旦张力被打破,粒子就会像弹性粒子那样弹跳,但同样也会继续彼此吸引,因此在弹跳过程中很可能成簇。

pd.flags = b2_tensileParticle;

这可以在surface tension这一个demo中看到。

(5)Viscous(黏性)

黏性粒子展现了附着感/粘稠感,例如油。

pd.flags = b2_viscousParticle;

这在Liquid Timer这个demo中能看到。

(6)Static Pressure(静态压力)

静压:物体液体相对静止时,液体对物体产生的压力。

粒子是容易受到压缩的。例如,当粒子被倒进一个容器里,底部粒子之间的排列会更紧密。

静压粒子将这种区别抹去:组中的每个粒子收到的压力都相同。

pd.flags = b2_staticPressureParticle;

(7)Wall(墙)

墙粒子是静态的,永远不动,即使发生碰撞也如此。

pd.flags = b2_WallParticle;

(8)Barrier(障碍)

固体/刚体粒子并不是内在地防止了tunneling:高速运动的粒子是可能穿过它们的。障碍粒子与其它粒子配合使用,可以使得粒子组tunneling-free。这在以下情景有用:确保液体粒子不会从墙粒子组成的容器中漏出去。

障碍粒子仅仅能阻止它们栖居的粒子组发生穿透,并不能防止粒子跑到两组粒子中间(即使组的位置让它们看起来像是相邻的)。

你可以把障碍粒子与elastic/spring/wall粒子一起用。

pd.flags = b2_WallParticle | b_barrierParticle;
pd.groupFlags = b2_solidParticleGroup;

(9)Zombie(僵尸)

僵尸粒子在你想要一步摧毁多个粒子的时候比较管用。所有被你设置成僵尸的粒子都会在solver的同一次迭代中同时被摧毁。这将提高性能(所需的时间与删除单个粒子的时间相同)。

group->GetFlagsBuffer()[i] |= b2_zombieParticle;

注意到,使用 | 可以将多个行为特征赋予给一个粒子(组)。

pd.groupFlags = b2_solidParticleGroup | b2_rigidParticleGoup;

若要使得组既具备组专属的特征、又有粒子的特征,使用两个语句:

pd.flags = b2_elasticParticle;
pd.groupFlags = b2_solidParticleGroup;

3.9 粒子属性

(1)Color-颜色

pd.color.Set(r, g, b, a);

分别为red, green, blue, opacity,值都在0-255之间。

(2)Size-大小

使用小粒子时要记住两点:

  • 对于粒子组来说,粒子的大小可能会影响性能。这是因为粒子大小与组成组的粒子个数成反比,而粒子越多效率越低。

    m_particleSystem->SetRadius(r);

    r为>0.0f的float32值,默认值为1.0f。

  • 小粒子也可能在爆炸等场景里会出现不可预测的行为(违反动量守恒定律)。通过减低gravity scale降低这些粒子的速度可能会让它们稳定下来。

    m_particleSystem->SetGravityScale(g);

    g是一个>0.0f的float32值,默认为1.0f。

还要注意的是,调整solver每步进行的粒子迭代数也可能会影响重力在粒子上的作用。迭代次数越大,对重力的抵抗就会越大。增加迭代次数的一个重要理由就是防止提及缩小(由重力导致的压缩)。

(3)Position(位置)

pd.position.Set(x,y);

x, y是粒子组平移的世界坐标。

(4)Velocity(速度)

对离散粒子来说,使用以下语句:

pd.velocity.Set(x,y);

x, y分别为沿x, y轴的速度。

对粒子组来说,使用如下语句:

pd.linearVelocity.Set(x,y);
pd.angularVelocity = aV;

x, y分别为沿x, y轴的速度,aV为组的旋转速度(aV rad/s)。

(5)Angles(角度)(专属于粒子组)

仅仅对rigid粒子组有用。这指明了粒子组倾斜的角度,用弧度表示,默认值为0。

pd.angle = a;

(6)Strength(力量)(专属于粒子组)

力量描述了粒子组的凝聚力,s为介于0.0(完全不凝聚)与1.0(非常凝聚)之间的值,默认为1.0。

pd.strength = s;

3.10 使用OpenGL进行渲染

粒子模块提供了通过OpenGL进行方便渲染的方法。

每种粒子性质都住在一个连续的记忆缓冲区里。如下表格提供了一个存储的的视觉展示。

image-20200714093359007

OpenGL可以在渲染的时候直接使用这些缓冲区。在这个例子中,OpenGL 1.1会使用glVertexPointer和glColorPointer从存储中得到这些值。OpenGL 2.0会使用glVertexAttribPointer。

OpenGL可以渲染单独粒子/粒子组。

3.11 样本应用

LiquidFun提供的样本中有两个应用展示了这个库的能力。

Testbed提供了大量的demo,展示了不同类型的粒子行为。有些demo是只能看的,有些demo是可以互动的。

EyeCandy只能在Android上运行,且提供了两份:它提供了一个简单的在Android上使用LiquidFun的例子,并且试图展示她在移动硬件上优秀的液体着色器。

运行程序时,你可以通过倾斜屏幕溅出液体,也可以通过点击屏幕切换不同的着色器。

4. Loose Ends

4.1 用户数据

b2Fixture, b2Body, b2Joint这几个类都允许你通过一个空指针连接你的用户数据,这在你检查LiquidFun的数据结构、决定它们怎么与你游戏引擎中的对象连接是很有用的。

例如,通常你都会把你的主角的指针与它身上的物体连接。这会形成循环引用(有actor能找到body,有body能找到actor)。

GameActor* actor = GameCreateActor();
b2BodyDef bodyDef;
bodyDef.userData = actor;
actor->body = box2Dworld->CreateBody(&bodyDef);

以下是几个你需要用户数据的例子:

(1)通过碰撞数据对主角造成一定的伤害;

(2)如果用户到达一个沿坐标轴的盒子里时播放一个编排好的事件;

(3)当LiquidFun告知你需要摧毁一个joint的时候获得一个游戏结构。

注意到用户数据是自选的,放啥都行,但应该始终保持一致。例如,如果你想在一个物体上存储一个actor的指针,就应该在所有物体上都存放一个actor的指针。

默认情况下,用户数据指针都是空的。

对于fixture来说,你可以考虑使用用户数据结构,来存放你所需要的游戏信息,例如材料、效果的链接、声音的链接。

struct FixtureUserData
{
    int materialIndex;
    ...
}
FixtureUserData myData = new FixtureUserData;
myData->materialIndex = 2;
b2FixtureDef fixtureDef;
fixtureDef.userData = myData;  ///
b2Fixture* fixture = body->CreateFixture(&fixtureDef);
...
delete fixture->GetUserData();
fixture->SetUserData(NULL);
body->DestroyFixture(fixture);

4.2 未被指明的摧毁(implicit destruction)

LiquidFun不使用引用计数,所以当你摧毁一个物体的时候它就真的没了。使用一个被摧毁物体的指针产生的结果各种各样,最大的可能性就是你的程序会崩。为了帮助解决这个问题,debug build memory manager fills使用FDFDFDFD来摧毁实体。

如果你摧毁了一个LiquidFun的实体,那么你就必须负责把所有它的引用也给删除。如果你对一个物体有多次引用,你最好考虑实现一个处理类(handle class)来处理这些指针。

当你使用LiquidFun你会创造、摧毁许多物体、形状、关节,大部分这些操作都是由LiquidFun自动完成的。如果你摧毁一个物体,那么相关的形状、关节都会被摧毁,这就叫做隐姓摧毁。这时任何一个和以上关节/contact连接的物体就会被唤醒,这个过程通常是有利的,但你要注意:

摧毁body时相关的fixture, joint都被自动删除。你必须要把与这些fixture, shape, joint相关的指针都给归零。(否则试图使用/摧毁这些指针将会出事)

LiquidFun为你提供了一个监视器类叫做b2DestructionListener,你可以实现后用在你的world对象上,这时world对象就会在由joint被隐性摧毁时通知你。

注意到joint/fixture被显性摧毁的时候也不会有通知,你也需要作相应的后续清理。如果可以的话,你也可以调用你自己的b2DestructionListener类中的清理代码。

隐性摧毁通常是便捷的,但你一定要注意。尤其是,关节通常在一段与相关物体管理无关的代码中被创造,例如,testbed创造了一个mousejoint用于与鼠标操作互动。

LiquidFun提供了一个callback机制,来在隐形摧毁的时候通知你,这在之后有描述。

class MyDestructionListener : public b2DestructionListener
{
    void SayGoodbye(b2Joint* joint)
    {
        //remove all references to joint
    }
}

你可以之后用world对象登记你的destruction listener,这应该在world的初始化中做:

myWorld->SetListener(myDestructionListener);

4.3 像素以及坐标系

再次回顾:LiquidFun使用MKS单位和弧度制。由于游戏是使用像素展示的,你可能会觉得用米有点麻烦。在Testbed中,作者将所有的游戏工作都在米中进行,只是使用OpenGL的视口变换(viewport transformation)将世界转换到屏幕大小。

float lowerX = -25.0f, upperX = 25.0f, lowerY = -5.0f, upperY = 25.0f;
gluOrtho2D(lowerX, upperX, lowerY, upperY);

如果你的游戏非得在像素世界中运行,那么你可以在从LiquidFun获得/传输数据的时候转换单位。这将提高物理模拟的稳定性。

你应该找到一个合适的换算标准,这最好根据你的主角的大小决定。例如,你的角色是75像素高,那么你就可以使用50像素/米。

xMeters = 0.02f * xPixels;

xPixels = 50.0f * xMeters;

最好呢,是在游戏代码中就使用MKS,只是在渲染的时候再转换成像素。这会简化你的游戏逻辑,并且减少错误,因为渲染的部分可以被孤立在一小部分代码里面完成。

如果你使用转换单位的方式,你最好全局地改变它,确保不出错。你也可以通过调整提高稳定性。

5. Debug Drawing

你可以实现b2DebugDraw这个类以得到物理世界的详细描绘。可用的实体有以下这些:

(1)形状轮廓;

(2)关节连接;

(3)AABBs;

(4)质心;

这是推荐的画物理实体的方法,而不是直接获取数据。理由是:这些重要的数据通常都存储在内部,且对改变易感。

testbed画了一些物理实体,使用的是debug draw这个功能还有contact listener。这展示了怎么使用debug drawing,以及怎么画接触点。

6. Limitations

LiquidFun使用了许多近似,来有效地模拟刚体物理。这会导致一些局限:

(1)将重的物体放在轻的物体上并不稳定,稳定性在比例超过10:1时会下降。

(2)对于用关节连接的chains of bodies,如果轻的物体在支撑重的物体,也会不稳定。例如,一个吊锤挂在一个chain的轻物体上就不是很稳定,超过10:1时就会这样。

(3)形状和形状碰撞之间通常有个0.5cm的差别。

(4)连续碰撞不能处理关节,所以高速运动的物体上关节可能会延展;

(5)LiquidFun使用symplectic Euler integration scheme(辛欧拉时间积分公式?)

(6)LiquidFun使用一个迭代solver来提高实时性能。你将没法得到非常精确的刚体碰撞/像素,提高迭代次数可以提高精度。