
우선 PBD를 알아보기 전에 전통적인 물리 시뮬레이션이 어떤 식으로 계산되었는지 알아볼 필요가 있다.
우리가 흔히 알고 있는 고전 역학의 공식들이 컴퓨터 안에서 어떻게 실시간으로 구현되어 왔는지, 그리고 그 과정에서 어떤 한계에 부딪혔는지를 이해한다면 PBD의 탄생 배경을 파악할 수 있다.
전통적인 물리 시뮬레이션은 크게 힘 기반(Force Based) 방식, 충격량 기반(Impulse Based) 방식 등이 이용되어왔다.

먼저 힘 기반 시뮬레이션은 뉴턴의 제2법칙()을 직접적으로 사용하는 가장 고전적인 방식이다.
물체에 가해지는 다양한 힘을 합산하여 가속도를 구하고, 이를 적분하여 최종적인 위치를 도출한다.
하지만 이 과정에서는 힘이 가속도와 속도를 거쳐 위치에 반영되기까지 최소 한 프레임 이상의 지연이 발생하는 반응 지연(Reaction Lag) 문제가 생긴다. 이로 인해 물체들이 서로 겹치는 현상을 해결하기가 매우 까다롭다.
또한, 물체를 더 단단하게 표현하기 위해 강성() 값을 높이면 계산값이 급격히 커지면서 물체가 화면 밖으로 튕겨 나가는 오버슈팅(Overshooting)과 수치적 불안정 현상이 발생한다.
반대로 안정성을 위해 강성을 낮추면 물체가 고무처럼 흐물거리는 현상이 나타나기 때문에, "절대 끊어지지 않는 줄"이나 "뚫리지 않는 벽" 같은 딱딱한 제약을 구현하기 위한 적절한 강성 값을 찾는 것은 매우 어려운 작업이었다.

이러한 힘 기반 방식의 불안정성을 보완하기 위해 등장한 것이 충격량 기반 시뮬레이션이다.
이 방식은 힘 대신 속도를 직접 변화시키는 충격량()을 사용하여 물체의 움직임을 제어한다. 속도를 직접 다루기 때문에 힘 기반 방식보다 오버슈팅이 적고 수치적으로 훨씬 안정적인 결과를 보여준다.
그러나 속도를 정교하게 일치시키더라도 미세한 계산 오차가 누적되면 시간이 지날수록 물체의 위치가 조금씩 어긋나는 드리프트(Drift) 현상을 피할 수 없다. 즉, 속도의 일관성이 반드시 위치의 일관성을 보장하지는 못한다는 근본적인 한계에 부딪히게 된다.

전통적인 방식들이 "어떤 힘을 가해야 이 물체가 저기로 이동할까?"를 고민했다면, 위치 기반 시뮬레이션(Position Based Simulation)은 과정보다 '결과(위치)'에 먼저 집중한다.
기존의 힘 기반 시뮬레이션에서는 두 물체가 겹치면 "서로 밀어내는 힘을 계산하고, 그 힘으로 가속도를 만들어 다음 프레임에 멀어지게" 유도했다. 하지만 PBD는 복잡한 물리 계산을 거치는 대신 아주 단순하게 접근한다. 물체가 겹친 것이 검출(Detection)되는 순간, 수학적인 복원력을 계산하는 대신 그냥 물체를 겹치지 않는 적절한 위치로 '강제 소환'해버리는 방식이다.
일단 물체를 원하는 위치에 옮겨 놓은 뒤, "이만큼 옮겨졌으니 속도는 이 정도겠군"이라고 나중에 속도를 역산(Update Velocities)하여 물리적 법칙을 맞춘다. 즉, 물리 법칙을 따라가다 보니 위치가 바뀌는 것이 아니라, 위치를 먼저 확정 짓고 물리가 그 뒤를 따르게 만드는 역발상이다.
이러한 위치 기반 접근 방식은 실시간 물리 시뮬레이션 환경에서 다음과 같은 논리적 이점을 제공한다.
조건 없는 안정성 (Unconditionally Stable)
PBD는 변위(Displacement)를 직접 제어한다. 제약 조건을 만족시키기 위해 이동할 수 있는 최대 거리가 현재의 오차 범위로 제한되므로, 힘의 누적으로 인한 오버슈팅(Overshooting)이나 시스템 붕괴가 원천적으로 차단된다.
위치 드리프트의 근본적 해결 (No Drift)
PBD는 매 타임스텝마다 제약 조건(Constraint)이라는 가이드라인에 입자의 위치를 직접 투영(Projection)한다. 결과적으로 기하학적 오차를 매 순간 0으로 수렴시키기 때문에 시간이 지나도 물리적 형태가 정교하게 유지된다.
그렇다면 구체적으로 어떤 단계를 거쳐 이 위치 수정이 일어나는지, PBD의 전체 알고리즘을 살펴보자.

PBD Pseudo Code
(1) forall vertices
(2) initialize
(3) endfor
(4) loop
(5) forall vertices do
(6) dampVelocities()
(7) forall vertices do
(8) forall vertices do generateCollisionConstraints()
(9) loop times
(10) projectConstraints()
(11) endloop
(12) forall vertices
(13)
(14)
(15) endfor
(16) velocityUpdate()
(17) endloop
논문에서 제안된 PBD 알고리즘의 전체 흐름은 의사코드에 따라 크게 네 가지 단계로 요약할 수 있다.
초기화 (Initialization): 각 입자의 초기 위치(), 속도(), 그리고 질량의 역수()를 설정한다. PBD 연산에서는 계산의 효율성을 위해 질량() 그 자체보다 역수인 을 주로 사용한다.
위치 예측 (Prediction): 현재 속도에 중력과 같은 외력()을 더해 가속도를 구하고, 이를 통해 다음 프레임에 입자가 위치할 예측 위치()를 먼저 계산한다.
제약 조건 해결 (Constraint Projection): 예측된 위치()가 물리적 제약(거리, 부피, 충돌 등)을 위반했는지 검사한다. 만약 위반했다면, 제약 조건을 만족하는 가장 가까운 지점으로 입자를 직접 강제 투영(Project)하여 위치를 수정한다.
갱신 (Integration & Update): 수정된 최종 위치를 바탕으로 실제 속도를 역산하여 업데이트하고, 다음 타임스텝을 준비한다.
알고리즘에 대한 이론적 이해를 마쳤으니, 이제 Unreal Engine 환경에서 Dynamic Mesh를 활용해 PBD 기반의 SoftBody를 실제로 구현해보자.
(1) forall vertices
(2) initialize
(3) endfor
PBD 시뮬레이션을 언리얼 엔진의 시스템과 연결하기 위해 가장 먼저 해야 할 일은 물리 데이터를 담을 입자(Particle) 구조체를 만드는 것이다.
의사코드의 (1)~(3) 단계에 해당하며, 시뮬레이션 시작 시 원본 메쉬의 Vertex 데이터를 읽어와 초기화한다.
// Particle Struct 추가
struct FSlimeParticle
{
FVector Position;
FVector PrevPosition;
FVector Velocity;
float Mass = 1.0f; // InvMass 어차피 1.0f
};
...
UCLASS()
class XPBD_IMPLEMENT_API ASlime : public AActor
{
GENERATED_BODY()
public:
...
// InitializePBD Function 추가
// Init for PBD
void InitializePBD();
...
TArray<FSlimeParticle> Particles;
void ASlime::BeginPlay()
{
Super::BeginPlay();
// Init for PBD
InitializePBD();
}
...
void ASlime::InitializePBD()
{
FDynamicMesh3* Mesh = DynamicMeshComp->GetDynamicMesh()->GetMeshPtr();
if (!Mesh) return;
Particles.Empty();
// Particle 생성
for (int32 vid : Mesh->VertexIndicesItr())
{
FVector3d Pos = Mesh->GetVertex(vid);
FSlimeParticle P;
P.Position = (FVector)Pos;
P.PrevPosition = P.Position;
P.Velocity = FVector::ZeroVector;
Particles.Add(P);
}
}
(5) forall vertices do
(6) dampVelocities()
(7) forall vertices do
예측(Prediction) 단계는 외력에 의해 입자가 다음에 위치할 곳을 가상으로 결정하는 과정으로, 의사코드의 (5)~(7) 단계에 해당한다.
제약 조건을 고려하기 전, 단순히 물리적인 가속도와 속도만을 이용해 입자의 미래 위치를 점쳐보는 단계이다.
UCLASS()
class XPBD_IMPLEMENT_API ASlime : public AActor
{
GENERATED_BODY()
public:
...
float Gravity = -980.0f;
// Called every frame
void ASlime::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
/*
* for all Particles i :
* v_i = v_i + g * dt | 속도 업데이트
* p_i_prev = p_i | 현재 위치 저장
* p_i = p_i + v_i * dt | 위치 업데이트
*/
for (FSlimeParticle& P : Particles)
{
P.Velocity.Z += Gravity * DeltaTime;
P.PrevPosition = P.Position;
P.Position += P.Velocity * DeltaTime;
}
...

제약 조건 해결(Constraint Projection)은 PBD 알고리즘의 가장 핵심적인 단계로, 앞서 예측된 위치()를 물리적 규칙에 맞게 강제로 교정하는 과정이다.
(9) loop times
(10) projectConstraints()
(11) endloop
의사코드의 (9)~(11) 단계에 해당하며, 소프트바디의 형태 유지(Distance, Volume), 충돌 처리가 이 단계에서 처리된다.
UCLASS()
class XPBD_IMPLEMENT_API ASlime : public AActor
{
GENERATED_BODY()
public:
...
UPROPERTY(EditAnywhere, Category = "XPBD")
int32 SolverIterations = 5;
// Called every frame
void ASlime::Tick(float DeltaTime)
{
...
/*
* 제약 조건 해결 (SolverIterations 만큼 반복)
* for all Constraints c :
* SolveConstraint(c)
*/
for (int32 Iter = 0; Iter < SolverIterations; Iter++)
{
// 거리 제약 조건 Solve
SolveDistanceConstraints(Constraints, DeltaTime);
// 부피 보존 제약 조건 Solve
SolveVolumeConstraints(DeltaTime);
}

슬라임의 기본적인 형태를 유지하기 위해, 먼저 거리 제약 조건(Distance Constraint)을 추가한다.
이 제약은 메시의 각 에지를 스프링처럼 취급하여, 두 파티클 사이의 거리가 초기 길이에서 크게 벗어나지 않도록 만든다.
즉, 슬라임을 구성하는 모든 파티클 쌍이 서로 연결된 구조를 가지게 되고, 이 구조가 무너지지 않도록 계속 보정하는 방식이다.
// DistanceConstraint Struct 추가
struct FDistanceConstraint
{
int32 A;
int32 B;
float RestLength;
};
...
UCLASS()
class XPBD_IMPLEMENT_API ASlime : public AActor
{
GENERATED_BODY()
public:
...
// 거리 제약 조건 Solver
void SolveDistanceConstraints(TArray<FDistanceConstraint>& Constraint, float DeltaTime);
...
TArray<FDistanceConstraint> Constraints;
void ASlime::InitializePBD()
{
...
Constraints.Empty();
// Constraint 생성
for (int32 eid : Mesh->EdgeIndicesItr())
{
UE::Geometry::FIndex2i Edge = Mesh->GetEdgeV(eid);
int32 A = Edge.A;
int32 B = Edge.B;
float RestLength = FVector::Distance(
Particles[A].Position,
Particles[B].Position
);
FDistanceConstraint C;
C.A = A;
C.B = B;
C.RestLength = RestLength;
Constraints.Add(C);
}
}
void ASlime::SolveDistanceConstraints(TArray<FDistanceConstraint>& Constraints, float DeltaTime)
{
/**
* PBD Distance Constraint
*
* C = |p1 - p2| - RestLength
* n = normalize(p1 - p2)
*
* Correction = (C / (w1 + w2)) * n * stiffness
*
* p1 -= w1 * Correction
* p2 += w2 * Correction
*/
for (FDistanceConstraint& C : Constraints)
{
FSlimeParticle& P1 = Particles[C.A];
FSlimeParticle& P2 = Particles[C.B];
FVector Delta = P1.Position - P2.Position;
float CurrentLength = Delta.Size();
if (CurrentLength < KINDA_SMALL_NUMBER)
continue;
float Cval = CurrentLength - C.RestLength;
FVector Grad = Delta / CurrentLength;
float w1 = 1.0f / P1.Mass;
float w2 = 1.0f / P2.Mass;
float wSum = w1 + w2;
if (wSum < KINDA_SMALL_NUMBER)
continue;
FVector Correction = (Cval / wSum) * Grad;
P1.Position -= w1 * Correction;
P2.Position += w2 * Correction;
}
}


Distance Constraint만 적용하면 슬라임의 구조는 유지되지만, 전체적으로 눌리거나 늘어날 때 부피가 자유롭게 변형되는 문제가 발생한다.
예를 들어
이 생길 수 있다.
이를 해결하기 위해 부피 보존 제약(Volume Constraint)을 추가한다.
이 제약은 슬라임 전체의 부피가 초기 상태와 크게 달라지지 않도록 만들어, 보다 자연스러운 소프트바디를 만들어 준다.
struct FTriangle
{
int32 A;
int32 B;
int32 C;
};
...
UCLASS()
class XPBD_IMPLEMENT_API ASlime : public AActor
{
GENERATED_BODY()
public:
...
// 부피 제약 조건 Solver
void SolveVolumeConstraints(float DeltaTime);
// Volume Util Function
float ComputeVolume();
void ComputeTriangleGradients(const FVector& A, const FVector& B, const FVector& C, FVector& GradA,
FVector& GradB, FVector& GradC);
...
TArray<FTriangle> Triangles;
float RestVolume = 0.0f;
void ASlime::InitializePBD()
{
...
// Triangle 생성
for (int32 tid : Mesh->TriangleIndicesItr())
{
UE::Geometry::FIndex3i Tri = Mesh->GetTriangle(tid);
FTriangle T;
T.A = Tri.A;
T.B = Tri.B;
T.C = Tri.C;
Triangles.Add(T);
}
RestVolume = ComputeVolume();
}
void ASlime::SolveVolumeConstraints(float DeltaTime)
{
/*
* PBD Volume Constraint
*
* V = Σ dot(A, cross(B, C)) / 6
*
* C(p) = V_current - V_rest
*
* ∇C1 = (x4 - x2) × (x3 - x2)
* ∇C2 = (x3 - x1) × (x4 - x1)
* ∇C3 = (x4 - x1) × (x2 - x1)
* ∇C4 = (x2 - x1) × (x3 - x1)
*
* Sum = Σ (w_i * |∇C_i|²)
*
* Scale = C / Sum
*
* Δx_i = - w_i * ∇C_i * Scale
*
* x_i += Δx_i
*/
float CurrentVolume = ComputeVolume();
float Constraint = CurrentVolume - RestVolume;
if (FMath::Abs(Constraint) < 1e-4f)
return;
TArray<FVector> Gradients;
Gradients.Init(FVector::ZeroVector, Particles.Num());
float Sum = 0.0f;
for (const FTriangle& Tri : Triangles)
{
int32 ia = Tri.A;
int32 ib = Tri.B;
int32 ic = Tri.C;
const FVector& A = Particles[ia].Position;
const FVector& B = Particles[ib].Position;
const FVector& Cpos = Particles[ic].Position;
FVector GradA, GradB, GradC;
ComputeTriangleGradients(A, B, Cpos, GradA, GradB, GradC);
Gradients[ia] += GradA;
Gradients[ib] += GradB;
Gradients[ic] += GradC;
}
for (int32 i = 0; i < Particles.Num(); i++)
{
float InvMass = 1.0f / Particles[i].Mass;
Sum += InvMass * Gradients[i].SizeSquared();
}
if (Sum < 1e-6f)
return;
float Scale = Constraint / Sum;
for (int32 i = 0; i < Particles.Num(); i++)
{
float InvMass = 1.0f / Particles[i].Mass;
Particles[i].Position -= InvMass * Gradients[i] * Scale;
}
}
void ASlime::ComputeTriangleGradients(const FVector& A, const FVector& B, const FVector& C, FVector& GradA, FVector& GradB, FVector& GradC)
{
GradA = FVector::CrossProduct(B, C) / 6.0f;
GradB = FVector::CrossProduct(C, A) / 6.0f;
GradC = FVector::CrossProduct(A, B) / 6.0f;
}
float ASlime::ComputeVolume()
{
float Volume = 0.0f;
for (const FTriangle& Tri : Triangles)
{
const FVector& A = Particles[Tri.A].Position;
const FVector& B = Particles[Tri.B].Position;
const FVector& C = Particles[Tri.C].Position;
Volume += FVector::DotProduct(A, FVector::CrossProduct(B, C)) / 6.0f;
}
return Volume;
}
월드 기준 Z=0 평면을 바닥으로 가정한 임시 충돌 처리이다.
PBD에서는 속도를 직접 수정하지 않고, 제약 조건 해결 단계에서 위치를 보정하는 방식으로 충돌을 처리한다.
따라서 Solver 반복 내부에서 파티클의 위치를 검사하고, 바닥 아래로 내려간 경우 위치를 위로 끌어올린다.
세부적인 충돌 처리는 다음에 상세하게 다시 처리하겠다.
// Called every frame
void ASlime::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
...
/*
* 제약 조건 해결 (SolverIterations 만큼 반복)
* for all Constraints c :
* SolveConstraint(c)
*/
for (int32 Iter = 0; Iter < SolverIterations; Iter++)
{
...
// Z < 0일 때 임시 충돌 처리
for (FSlimeParticle& P : Particles)
{
// 로컬 → 월드
FVector WorldPos = GetActorTransform().TransformPosition(P.Position);
if (WorldPos.Z < 0.0f)
{
WorldPos.Z = 0.0f;
// 월드 → 로컬
P.Position = GetActorTransform().InverseTransformPosition(WorldPos);
}
}
}
제약 조건 해결 단계에서 입자들의 새로운 위치()가 확정되면, 마지막으로 이 결과값을 실제 물리 상태로 반영하는 통합 및 갱신 과정을 거친다.
이는 의사코드의 (12)~(16) 단계에 해당하며, 시뮬레이션의 연속성을 유지하는 중요한 단계이다.
(13)
제약 조건에 의해 보정된 최종 위치()와 이전 프레임의 위치() 차이를 이용하여 현재 속도를 계산한다.
(14)
PBD 연산을 통해 최종적으로 결정된 파티클의 위치()를 시각적인 메쉬 데이터로 전달하여 화면에 출력하는 과정이다. 언리얼 엔진의 UDynamicMeshComponent가 제공하는 EditMesh 함수를 사용하여 연산 결과를 GPU 렌더링 리소스에 직접 반영한다.
이 과정에서 가장 핵심적인 작업은 정점 위치 동기화이다.
Mesh.SetVertex를 호출하여 시뮬레이션된 각 파티클의 위치 좌표를 실제 메쉬의 정점 좌표로 덮어씌운다.
EDynamicMeshAttributeChangeFlags::VertexPositions를 지정함으로써 UV나 노멀(Normal) 등 다른 데이터는 제외하고 오직 정점의 위치 값만 갱신되었음을 엔진에 알린다. 이를 통해 불필요한 데이터 재계산을 방지하고 렌더링 리소스 갱신 효율을 극대화한다.
마지막으로 수정된 메쉬 데이터를 확정 짓기 위해 NotifyMeshUpdated를 호출하여 시각적인 변화를 완결하며, UpdateCollision을 통해 변형된 형태에 맞는 물리적 충돌 영역까지 실시간으로 동기화한다.
// Slime.cpp
void ASlime::Tick(float DeltaTime)
{
...
/*
* for all Particles i :
* v_i = (p_i - p_i_prev) / dt | 속도 업데이트
*/
for (FSlimeParticle& P : Particles)
{
P.Velocity = (P.Position - P.PrevPosition) / DeltaTime;
}
DynamicMeshComp->GetDynamicMesh()->EditMesh(
[this](FDynamicMesh3& Mesh)
{
for (int32 vid : Mesh.VertexIndicesItr())
{
Mesh.SetVertex(vid, static_cast<FVector3d>(Particles[vid].Position));
}
},
EDynamicMeshChangeType::GeneralEdit,
EDynamicMeshAttributeChangeFlags::VertexPositions
);
DynamicMeshComp->NotifyMeshUpdated();
DynamicMeshComp->UpdateCollision();
}

Tick의 Constraint 반복 횟수(SolverIterations)를 늘리면 Softbody의 강도를 조절할 수 있다.
Solver가 한 프레임 안에서 제약을 더 많이 반복해서 해결할수록, 파티클 간의 거리와 부피가 더 정확하게 유지되기 때문이다.
과 같은 차이가 나타난다.

현재 구현된 PBD는 SolverIterations과 FPS의 변화에 따라 SoftBody의 상태가 달라지는 문제점이 존재한다.
예를 들어, FPS가 낮아지면 Δt가 커지면
- 같은 SolverIterations을 사용해도 제약이 덜 정확하게 해결됨
- 결과적으로 슬라임이 더 늘어지거나 형태가 무너짐
FPS가 높아지면 Δt가 작아지면
- 같은 반복 횟수에서도 제약이 더 강하게 적용됨
- 슬라임이 더 단단하게 보임
해당 문제를 개선한 버전이 XPBD(Extended Position-Based Dynamics)이다.
이후 해당 코드를 XPBD로 업그레이드하는 방법을 서술하겠다.
참고자료
Writing a soft body cube in C/C++ using XPBD | Devlog Episode 1
Making the Stanford Bunny explode using XPBD | Devlog Episode 2
Increasing my Soft Body's Bending Resistance using XPBD | Devlog Episode 3Ten Minute Physics
09 Getting ready to simulate the world with XPBD
10 Simple and unbreakable simulation of soft bodies