阅读记录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

VAB=VBVA

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

n用于表示碰撞的法向量

Equation 2

VABn=(VBVA)n

Equation 3 点乘式子

V1=[x1y1],V2=[x2y2]V1V2=x1x2+y2y2

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

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

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

Equation 4

V=eV

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

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

VABn=e(VBVA)n

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

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

Equation 6

V=V+jn

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

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

Equation 7

Impulse=massVelocityVelocity=ImpulsemassV=V+jnmass

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

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

VA=VAjnmassAVB=VB+jnmassB

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

Equation 9

(VBVA+jnmassA+jnmassB)n=e(VBVA)n(VBVA+jnmassA+jnmassB)n+e(VBVA)n=0

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

Equation 10

(VBVA)n+j(nmassA+nmassB)n+e(VBVA)n=0(1+e)((VBVA)n)+j(nmassA+nmassB)n=0j=(1+e)((VBVA)n)1massA+1massB

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=0Normal to line:[ab]

custom-physics-line2d

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

2021.8.25

作者:FlyingZiming

出处:https://www.cnblogs.com/FlyingZiming/p/15184189.html

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

但行好事,莫问前程。

posted @   飞翔的子明  阅读(176)  评论(0编辑  收藏  举报
编辑推荐:
· [.NET]调用本地 Deepseek 模型
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· .NET Core 托管堆内存泄露/CPU异常的常见思路
· PostgreSQL 和 SQL Server 在统计信息维护中的关键差异
· C++代码改造为UTF-8编码问题的总结
阅读排行:
· 【.NET】调用本地 Deepseek 模型
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
点击右上角即可分享
微信分享提示
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu