阅读记录How to Create a Custom 2D Physics Engine - Rangy Gaul(1)

本文为物理相关文章的阅读记录,包括了翻译、个人勘误以及一些个人理解,原文链接https://gamedevelopment.tutsplus.com/series/how-to-create-a-custom-physics-engine--gamedev-12715

A custom physics engine can tackle any sort of technical effect the creator has the skill to create.自定义物理引擎可以处理创作者有能力创造的任何技术效果。

The Basics and Impulse Resolution

Axis Aligned Bounding Boxes

基础讲了AABB,拓展有AABB的intersecting检测

AABB盒为复杂几何体做用于一个简单测试

An example AABB

AABB的数据结构:

struct AABB
{
  Vec2 min;
  Vec2 max;
};

In order to tell whether two AABB shapes are intersecting you will need to have a basic understanding of the Separating Axis Theorem (SAT).

Here's a quick test taken from Real-Time Collision Detection by Christer Ericson, which makes use of the SAT:实时碰撞检测中AABB测试的SAT的算法大致如下

bool AABBvsAABB( AABB a, AABB b )
{
  // Exit with no intersection if found separated along an axis
  if(a.max.x < b.min.x or a.min.x > b.max.x) return false
  if(a.max.y < b.min.y or a.min.y > b.max.y) return false
 
  // No separating axis found, therefor there is at least one overlapping axis
  return true
}

Circle

数据结构

struct Circle
{
  float radius
  Vec position
};

测试两个圆是否相交很简单:取两个圆的半径并将它们相加,然后检查这个和是否大于两个圆之间的距离。

计算距离时一般使用平方而不开根号。

冲量方法

是一种碰撞解决方法

这里给出的是2维的例子

一般来说,物理引擎中的物体有三个主要的自由度(在二维空间中):在xy平面上的运动(2个)和垂直平面轴的旋转(1个)。在本文中,我们隐式地限制旋转,只使用aabb和圆,所以我们真正需要考虑的唯一自由度是沿着xy平面的运动。

碰撞处理的目的是我们要让这两个参与碰撞的物体不再Intersecting。

冲量方法的核心就是使用一个速度突变值来分离物体。为了做到这一点,必须以某种方式考虑每个物体的质量、位置和速度:我们希望大的物体与小的物体碰撞时,在碰撞过程中移动一点,并让小的物体飞离。我们还希望质量无穷大的物体完全不运动。

Simple example of what impulse resolution can achieve

我们将使用刚体和一些数学知识。刚体的定义是受到外力后不可变形的物体。如果可变形我们物理计算公式的复杂度将提升一个档次。

碰撞检测得到碰撞法线+渗透深度

为了给两个物体施加脉冲使它们分开,我们需要知道推动它们的方向和力度。碰撞法线是冲击作用的方向。穿透深度(以及其他一些东西)决定了脉冲的大小。这意味着唯一需要解的值就是脉冲的大小。

Equation 1

\[V^{AB} = V^B - V^A \]

从A指向B的向量,我们计算时的公式是:末位点 减去 开始点

n用于表示碰撞的法向量

Equation 2

\[V^{AB} \cdot n = (V^B - V^A) \cdot n \]

Equation 3 点乘式子

\[V_1 = \begin{bmatrix}x_1 \\y_1\end{bmatrix}, V_2 = \begin{bmatrix}x_2 \\y_2\end{bmatrix} \\ V_1 \cdot V_2 = x_1 * x_2 + y_2 * y_2 \]

下一步是引入所谓的恢复系数e。恢复是指弹性或弹性,可能中学物理学的弹性碰撞的那个系数就是指这个东西。你应该总是使用碰撞中最低恢复系数。

// Given two objects A and B
e = min( A.restitution, B.restitution )

结合牛顿恢复定律,用恢复系数 计算得到一个碰撞后的速度

Equation 4

\[V' = e * V \]

这就是说,碰撞后的速度等于碰撞前的速度,乘以某个常数。这个常数表示“反弹因子”。

整合到Equation 5(和原文有改动,我加了 ' )

\[V^{'AB} \cdot n = -e * (V^B - V^A) \cdot n \]

在牛顿恢复定律中,反弹后的矢量,实际上是指向V的反方向,那么在推导过程中,我们如何表示反方向呢?引入一个负号。

现在我们需要能够表达在脉冲影响下的速度。下面是一个简单的方程,用于沿特定方向用脉冲标量j沿着特定方向n修改速度矢量

Equation 6

\[V' = V + j * n \]

其实就是把一个想来你加到另一个向量上,j其实就表示这个冲量的大小

在形式上,冲量被定义为动量的变化。动量是质量*速度。

Equation 7

\[Impulse = mass * Velocity \\ Velocity = \frac{Impulse}{mass} \therefore V' = V + \frac{j * n}{mass} \]

当物体a和物体b发生碰撞时,a会被推向b相反的方向:

Equation 8 (这里的正负号也有修改,我是觉得碰撞A的话用减更好,符号和碰撞向量的定义有关,取正负我觉得都可以,这里取负主要是为了和后面的推导过程不冲突)

\[V'^A = V^A - \frac{j * n}{mass^A} \\ V'^B = V^B + \frac{j * n}{mass^B} \]

现在需要做的就是合并方程8和5,下式减去上式然后左边代入公式5,再左右移项。我们得到的方程是这样的:(这里也和原文有改动,估计是原文公式写错了)

Equation 9

\[(V^B - V^A + \frac{j * n}{mass^A} + \frac{j * n}{mass^B}) * n = -e * (V^B - V^A) \cdot n \\ \therefore \\ (V^B - V^A + \frac{j * n}{mass^A} + \frac{j * n}{mass^B}) * n + e * (V^B - V^A) \cdot n = 0 \]

这里也有改动,j应该是提出来了

Equation 10

\[(V^B - V^A) \cdot n + j * (\frac{n}{mass^A} + \frac{n}{mass^B}) * n + e * (V^B - V^A) \cdot n = 0 \\ \therefore \\ (1 + e)((V^B - V^A) \cdot n) + j * (\frac{ n}{mass^A} + \frac{ n}{mass^B}) * n = 0 \\ \therefore \\ j = \frac{-(1 + e)((V^B - V^A) \cdot n)}{\frac{1}{mass^A} + \frac{1}{mass^B}} \]

n是方向向量,单位向量,自乘就等于1

这意味着我们可以编写几行代码来求解脉冲标量(j)。代码比数学符号可读性强多了!

void ResolveCollision( Object A, Object B )
{
  // Calculate relative velocity
  Vec2 rv = B.velocity - A.velocity
 
  // Calculate relative velocity in terms of the normal direction
  float velAlongNormal = DotProduct( rv, normal )
 
  // Do not resolve if velocities are separating
  if(velAlongNormal > 0)
    return;
 
  // Calculate restitution
  float e = min( A.restitution, B.restitution)
 
  // Calculate impulse scalar
  float j = -(1 + e) * velAlongNormal
  j /= 1 / A.mass + 1 / B.mass
 
  // Apply impulse
  Vec2 impulse = j * normal
  A.velocity -= 1 / A.mass * impulse
  B.velocity += 1 / B.mass * impulse
}

在上面的代码示例中,有几件关键的事情需要注意。首先是第10行检查,if(VelAlongNormal > 0).这个检查非常重要;它确保只在物体相互移动时才解决碰撞。

Two objects collide but velocity will separate them next frame Do not resolve this type of collision

通常会记住质量的导数

A.inv_mass = 1 / A.mass

许多物理引擎实际上并不存储原始质量。物理引擎通常只存储质量倒数。碰巧大多数涉及质量的数学都是使用质量倒数。

最后要注意的是,我们智能地将脉冲标量(j)分配到两个对象上。我们希望小的物体的(j)比较大,大的物体的速度只被一小部分的(j)所改变。

float mass_sum = A.mass + B.mass
float ratio = A.mass / mass_sum
A.velocity -= ratio * impulse
 
ratio = B.mass / mass_sum
B.velocity += ratio * impulse

Sinking Object问题

如果我们继续使用到目前为止的代码,对象将会相互碰撞并反弹。这很好,不过如果一个物体的质量是无穷大呢?我们需要一个好方法来表示无限质量在我们的模拟中。

我建议用零作为无限质量——尽管如果我们试图计算一个物体的质量为零的逆,我们将会被零除法。解决这个问题的方法是在计算逆质量时做下面的事情:

if(A.mass == 0)
  A.inv_mass = 0
else
  A.inv_mass = 1 / A.mass

简单的说就是设置特殊值

这还是可以的。Sinking Objects问题出现在物体由于重力开始下沉到另一个物体时。也许是某种恢复力很低的东西撞上了质量无限的墙,然后开始下沉。

这种下沉是由于浮点错误。在每次浮点运算过程中,由于硬件的原因,都会引入一个小的浮点误差。随着时间的推移,这个错误累积在位置错误中,导致对象陷入另一个对象中。

为了纠正这个错误,必须对它进行解释。为了纠正这个位置误差,我将向你展示一种叫做线性投影的方法。线性投影使两个物体的穿透率降低了一个小百分比,这是在施加脉冲后进行的。位置校正非常简单:按穿透深度的百分比沿碰撞法(n)移动每个物体:

void PositionalCorrection( Object A, Object B )
{
  const float percent = 0.2 // usually 20% to 80%
  Vec2 correction = penetrationDepth / (A.inv_mass + B.inv_mass)) * percent * n
  A.position -= A.inv_mass * correction
  B.position += B.inv_mass * correction

这就是位置级别的纠正,有类似PBD的思想

请注意,我们用系统的总质量来衡量穿透深度。这将给出一个与我们所处理的质量成比例的位置修正。小的物体比重的物体推得快。

这个实现有一个小问题:如果我们总是解决我们的位置错误,那么对象将在相互依赖时来回抖动。为了防止这种情况的发生,必须采取一些宽松措施。我们只在穿透超过某个被称为“slop”的任意阈值时执行位置校正:

void PositionalCorrection( Object A, Object B )
{
  const float percent = 0.2 // usually 20% to 80%
  const float slop = 0.01 // usually 0.01 to 0.1
  Vec2 correction = max( penetration - k_slop, 0.0f ) / (A.inv_mass + B.inv_mass)) * percent * n
  A.position -= A.inv_mass * correction
  B.position += B.inv_mass * correction
}

这使得物体可以在没有位置校正的情况下略微穿透。

简单流型生成

本文要讨论的最后一个主题是简单流形生成。在数学术语中,流形是“在空间中代表一个区域的点的集合”。然而,当我提到术语流形时,我指的是一个包含关于两个对象之间碰撞信息的小对象

其实理解成碰撞信息就好了,在冲量方法中就是渗透深度和法向量

struct Manifold
{
  Object *A;
  Object *B;
  float penetration;
  Vec2 normal;
};

在碰撞检测过程中,既要计算穿透度,也要计算碰撞法线。为了找到这些信息,必须对本文开头的原始碰撞检测算法进行扩展。这里比较长就建议看原文了。

Core Engine

积分

F = ma

Erin Catto's Integration Demo from GDC 2009 and Hannu's addition to symplectic Euler for more stability in low FPS environments.

// Explicit Euler
x += v * dt
v += (1/m * F) * dt

dt表示delta time

// Symplectic Euler
v += (1/m * F) * dt
x += v * dt

为何需要定步长?

我们不希望游戏在不同电脑上的物理的运行速度和电脑的性能有关

我们希望我们物理引擎每次只按定时间步长运作

Using the exact same dt value in your code everywhere will actually make your physics engine deterministic, and is known as a fixed timestep

在你的代码中使用完全相同的dt值将使你的物理引擎具有确定性,这被称为固定的时间步长。使用固定时间步长的其他理由:verlet积分的使用对步长有要求

在守望先锋中服务器和客户端为了两端模拟的确定性,也采用了定时间步长发包的方式。

假设给出了相同的输入,确定性物理引擎总是在每次运行时都做同样的事情。

const float fps = 100
const float dt = 1 / fps
float accumulator = 0
 
// In units of seconds
float frameStart = GetCurrentTime( )
 
// main loop
while(true)
  const float currentTime = GetCurrentTime( )
 
  // Store the time elapsed since the last frame began
  accumulator += currentTime - frameStart( )
 
  // Record the starting of this frame
  frameStart = currentTime
 
  while(accumulator > dt)
    UpdatePhysics( dt )
    accumulator -= dt
 
  RenderGame( )

记录过去的时间,当其满足了一次物理迭代时就进行物理引擎更新,或许我们应该把这个放到别的线程,主线程和物理线程用同步的方法去做

Chunks of dt are removed from the accumulator until the accumulator is smaller than a dt chunk.

这个循环是有问题的,物理引擎的更新也作为当前帧的时间计算了,如果物理算的太久,就一直卡物理了,这就是死亡螺旋

为了解决这个问题,如果累加器变得过高,引擎就需要运行更少的物理更新。一个简单的方法是将累加器固定在某个任意值以下。

// Avoid spiral of death and clamp dt, thus clamping
  // how many times the UpdatePhysics can be called in
  // a single game loop.
  if(accumulator > 0.2f)
    accumulator = 0.2f

物理机制不会死亡,但这样还是会运行的慢一些,而且这样还会有物理跳跃现象

const float fps = 100
const float dt = 1 / fps
float accumulator = 0
 
// In units seconds
float frameStart = GetCurrentTime( )
 
// main loop
while(true)
  const float currentTime = GetCurrentTime( )
 
  // Store the time elapsed since the last frame began
  accumulator += currentTime - frameStart( )
 
  // Record the starting of this frame
  frameStart = currentTime
 
  // Avoid spiral of death and clamp dt, thus clamping
  // how many times the UpdatePhysics can be called in
  // a single game loop.
  if(accumulator > 0.2f)
    accumulator = 0.2f
 
  while(accumulator > dt)
    UpdatePhysics( dt )
    accumulator -= dt
 
  const float alpha = accumulator / dt;
 
  RenderGame( alpha )
 
void RenderGame( float alpha )
  for shape in game do
    // calculate an interpolated transform for rendering
    Transform i = shape.previous * alpha + shape.current * (1.0f - alpha)
    shape.previous = shape.current
    shape.Render( i )

令渲染落后物理一帧,然后插值平滑所有的累加器余量。这样的实际表现就会显得很平滑

为什么不去让渲染快一点呢,去插值到当前物理的位置+时间累加器预测的位置?

1、这样需要预测

2、物理引擎中突变的情况很多,对于这种突变的预测产生的错误预测往往会导致一些瞬移

模块设计

Bodies 物理主体是一个包含了关于某个给定物理对象的所有信息的对象。它将存储对象所代表的形状、大量数据、变换(位置、旋转)、速度、扭矩等。

struct body
{
  Shape *shape;
  Transform tx;
  Material material;
  MassData mass_data;
  Vec2 velocity;
  Vec2 force;
  real gravityScale;
};

body应该有多种Shape,以组合的方式提供,可以添加多种shape

struct MassData
{
  float mass;
  float inv_mass;
 
  // For rotations (not covered in this article)
  float inertia;
  float inverse_inertia;
};

对旋转就有转动惯量

力,矢量,力在每次迭代计算,每次迭代都不同,算完之后可以归零

宽相位计算

我们使用宽相位碰撞检测来确定哪些对物体可能发生碰撞,然后用窄相位碰撞检测来检查它们是否真的发生碰撞

struct Pair
{
  body *A;
  body *B;
};

广义相应该收集一系列可能的碰撞,并将它们全部存储在Pair结构中。这些对可以传递到引擎的另一部分(狭窄阶段),然后解决。

这是一个O(n^2)的方法,使用空间数据结构可以将它降到O(n)

// Generates the pair list.
// All previous pairs are cleared when this function is called.
void BroadPhase::GeneratePairs( void )
{
  pairs.clear( )
 
  // Cache space for AABBs to be used in computation
  // of each shape's bounding box
  AABB A_aabb
  AABB B_aabb
 
  for(i = bodies.begin( ); i != bodies.end( ); i = i->next)
  {
    for(j = bodies.begin( ); j != bodies.end( ); j = j->next)
    {
      Body *A = &i->GetData( )
      Body *B = &j->GetData( )
 
      // Skip check with self
      if(A == B)
        continue
 
      A->ComputeAABB( &A_aabb )
      B->ComputeAABB( &B_aabb )
 
      if(AABBtoAABB( A_aabb, B_aabb ))
        pairs.push_back( A, B )
    }
  }
}

这里也会有很多重复的对子,我们需要将他们排序,删除重复的

// Sort pairs to expose duplicates
sort( pairs, pairs.end( ), SortPairs );
 
// Queue manifolds for solving
{
  int i = 0;
  while(i < pairs.size( ))
  {
    Pair *pair = pairs.begin( ) + i;
    uniquePairs.push_front( pair );
 
    ++i;
 
    // Skip duplicate pairs by iterating i until we find a unique pair
    while(i < pairs.size( ))
    {
      Pair *potential_dup = pairs + i;
      if(pair->A != potential_dup->B || pair->B != potential_dup->A)
        break;
      ++i;
    }
  }
}
bool SortPairs( Pair lhs, Pair rhs )
{
  if(lhs.A < rhs.A)
    return true;
 
  if(lhs.A == rhs.A)
    return lhs.B < rhs.B;
 
  return false;
}

术语lhs和rhs可以理解为“左手边”和“右手边”。这些术语通常用来指函数的参数,在这些参数中,事物在逻辑上可以被视为某个方程或算法的左右两边。

分层

不同层的body不会碰撞

// Generates the pair list.
// All previous pairs are cleared when this function is called.
void BroadPhase::GeneratePairs( void )
{
  pairs.clear( )
 
  // Cache space for AABBs to be used in computation
  // of each shape's bounding box
  AABB A_aabb
  AABB B_aabb
 
  for(i = bodies.begin( ); i != bodies.end( ); i = i->next)
  {
    for(j = bodies.begin( ); j != bodies.end( ); j = j->next)
    {
      Body *A = &i->GetData( )
      Body *B = &j->GetData( )
 
      // Skip check with self
      if(A == B)
        continue
 
      // Only matching layers will be considered
      if(!(A->layers & B->layers))
        continue;
 
      A->ComputeAABB( &A_aabb )
      B->ComputeAABB( &B_aabb )
 
      if(AABBtoAABB( A_aabb, B_aabb ))
        pairs.push_back( A, B )
    }
  }
}

Halfspace Intersection

二维中,半空间表示一条直线的一面

判断在那一边,把点代入线的方程,然后看符号,结果为0表示该点在直线上,正/负表示直线的不同边。

\[General \ form:ax+by+c=0\\ Normal\ to\ line:\begin{bmatrix} a\\b \end{bmatrix} \]

custom-physics-line2d

法向量不一定是标准化的(也就是说,它的长度不一定是 1)。

2021.8.25

posted @ 2021-08-25 11:19  飞翔的子明  阅读(155)  评论(0编辑  收藏  举报