[Unreal Engine] PBD Collision Constraint

이매·2026년 2월 25일

Unreal Physics

목록 보기
3/5
post-thumbnail

1. Sphere Collision 생성

지난 PBD 구현 때는 Collision 처리를 임시로 다음과 같이 진행했다.

// 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);
			}
		}
	}

하지만 이건 그냥 World 좌표 기준으로 z = 0 이하로 떨어지지 않도록한 임시 처리이다.

이걸 이제 실제로 물리적 충돌 판정을 이용하여 PBD의 Collision Constraint를 만들어보자.

원래는 SoftBody 자체의 Complex Collision을 이용하여 충돌 판정을 구현하려 했으나, 여러 이슈들로 인해 Sphere Collision을 대체하여 이용하겠다.

Slime.h 수정

...

class USphereComponent;

...

UCLASS()
class XPBD_IMPLEMENT_API ASlime : public AActor
{
	GENERATED_BODY()
public:

	UFUNCTION()
	void OnSphereOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
	UFUNCTION()
	void OnSphereEndOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex);
	

	...

	UPROPERTY(VisibleAnywhere, Category = "Physics")
	USphereComponent* SphereCollision;
    
    UPROPERTY(EditAnywhere, Category = "XPBD")
	float SphereRadius = 60.0f;

Slime.cpp 수정

#include "Components/SphereComponent.h"

...

ASlime::ASlime()
{
 	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = true;

	SphereCollision = CreateDefaultSubobject<USphereComponent>(TEXT("SphereCollision"));
	RootComponent = SphereCollision;
	SphereCollision->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
	SphereCollision->SetNotifyRigidBodyCollision(true);
	SphereCollision->SetGenerateOverlapEvents(true);
	SphereCollision->SetSimulatePhysics(false);
	SphereCollision->SetEnableGravity(false);
	SphereCollision->SetupAttachment(DynamicMeshComp);
	SphereCollision->SetSphereRadius(SphereRadius);
	SphereCollision->SetLineThickness(1.0f);
	SphereCollision->bHiddenInGame = false;
	SphereCollision->bReceivesDecals = false;
	
	// Sphere Collision 오버랩 이벤트 바인딩
	SphereCollision->OnComponentBeginOverlap.AddDynamic(this, &ASlime::OnSphereOverlap);
	SphereCollision->OnComponentEndOverlap.AddDynamic(this, &ASlime::OnSphereEndOverlap);
	
	// DynamicMeshComponent는 시각적 표현만 담당
	DynamicMeshComp = CreateDefaultSubobject<UDynamicMeshComponent>(TEXT("DynamicMeshComp"));
	DynamicMeshComp->SetupAttachment(RootComponent);
	DynamicMeshComp->SetCollisionEnabled(ECollisionEnabled::NoCollision);
	DynamicMeshComp->SetCollisionObjectType(ECC_PhysicsBody);
	DynamicMeshComp->SetCollisionResponseToAllChannels(ECR_Block);
	DynamicMeshComp->SetMobility(EComponentMobility::Movable);
	DynamicMeshComp->bReceivesDecals = false;
    
    ...
    


2. Collision Optimization

지난번에 구현한 PBD에서는 SoftBody의 Complex Collision을 업데이트 하기 위한 코드를 만들었다.

하지만 현재 우리는 Sphere Collision으로 오버랩 이벤트를 이용할 것이기 때문에 필요하지 않다.
그리고 Unreal Insight에서도 볼 수 있듯 Complex Collision을 업데이트 하는 비용이 매우 비싸기 때문에 이에 대한 코드를 제거한다.

Slime.cpp 수정

void ASlime::OnConstruction(const FTransform& Transform)
{
	Super::OnConstruction(Transform);
	
	...

	// DynamicMeshComponent에 적용
	DynamicMeshComp->GetDynamicMesh()->SetMesh(MoveTemp(DynMesh));
	DynamicMeshComp->SetComplexAsSimpleCollisionEnabled(false, false); // 변경
	DynamicMeshComp->NotifyMeshUpdated();
    
    ...
}

void ASlime::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
    
    ...
    
    /*
	 * 제약 조건 해결 (SolverIterations 만큼 반복)
	 * for all Constraints c :
	 *	SolveConstraint(c)
	 */
	for (int32 Iter = 0; Iter < SolverIterations; Iter++)
	{
		// 거리 제약 조건 Solve
		SolveDistanceConstraints(Constraints, DeltaTime);
		
		// 부피 보존 제약 조건 Solve
		SolveVolumeConstraints(DeltaTime);	
		
		// // Z < 0일 때 임시 충돌 처리 -> 삭제 Collision Constraint 함수 만들 것.
		// for (FSlimeParticle& P : Particles)
		// {
		// 	// 로컬 → 월드
		// 	FVector WorldPos = GetActorTransform().TransformPosition(P.Position);
		//
		// 	if (WorldPos.Z < 0.0f)
		// 	{
		// 		WorldPos.Z = 0.0f;
		//
		// 		// 월드 → 로컬
		// 		P.Position = GetActorTransform().InverseTransformPosition(WorldPos);
		// 	}
		// }
	}
	}
    
    // Complex Collision Update 삭제
    //DynamicMeshComp->NotifyMeshUpdated();
	//DynamicMeshComp->UpdateCollision();   
}


3. Collision Constraint

3-1. Collision Constraint

위에서 추가한 SphereCollision 컴포넌트를 이용해 오버랩을 감지한다.
오버랩이 감지되면, 각 파티클에 대해 PBD(Position Based Dynamics) 기반 충돌 제약을 직접 해결하는 방식을 사용한다.

Slime.h 수정

UCLASS()
class XPBD_IMPLEMENT_API ASlime : public AActor
{
	GENERATED_BODY()
public:

	...
    
    // 충돌 제약 조건 Solver
	void SolveCollision(float DeltaTime);
    
    ...
    
    UPROPERTY()
	TSet<TObjectPtr<AActor>> OverlappingActors; // 다중 충돌 액터 감지용
    
    float Friction = 0.1f;				// 마찰 계수

Slime.cpp 수정

void ASlime::OnSphereOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp,
	int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	if (!IsValid(OtherActor) || OtherActor == this)
	{
		return;
	}
	
	OverlappingActors.Add(OtherActor);
}

void ASlime::OnSphereEndOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp,
	int32 OtherBodyIndex)
{
	if (!IsValid(OtherActor) || OtherActor == this)
	{
		return;
	}
	
	OverlappingActors.Remove(OtherActor);
}

void ASlime::SolveCollision(float DeltaTime)
{
	if (OverlappingActors.Num() == 0)
		return;
	
	TArray<FHitResult> Hits;

	FVector Start = SphereCollision->GetComponentLocation();
	FVector End = Start;

	FCollisionQueryParams Params;
	Params.AddIgnoredActor(this);
	
	FCollisionObjectQueryParams ObjParams;
	ObjParams.AddObjectTypesToQuery(ECC_WorldStatic);
	ObjParams.AddObjectTypesToQuery(ECC_WorldDynamic);
	ObjParams.AddObjectTypesToQuery(ECC_Pawn);

	bool bHit = GetWorld()->SweepMultiByObjectType(
		Hits,
		Start,
		End,
		FQuat::Identity,
		ObjParams,
		FCollisionShape::MakeSphere(SphereRadius),
		Params
	);
	
	if (!bHit) return;
	
	bool bImpulseHit = false;
	FVector ImpulseContactPoint;
	FVector ImpulseContactNormal;
	float ImpulseStrength = 0.0f;
	
	FTransform ActorTransform = GetActorTransform();
	int32 GroundContactCount = 0;

	/*
	 * 충돌 처리 로직
	 * 
	 * 1. 모든 Hit에 대해 반복:
	 *    - 충돌 지점과 법선 벡터 추출
	 *    - 액터 타입에 따라 임펄스 강도 결정 (플레이어: 300, 슬라임: 150)
	 */
	for (const FHitResult& Hit : Hits)
	{
		FVector ContactPoint = Hit.ImpactPoint;
		FVector ContactNormal = Hit.ImpactNormal;
		
		if (Hit.GetActor()->IsA<APawn>())
		{
			bImpulseHit = true;
			ImpulseContactPoint = ContactPoint;
			ImpulseContactNormal = ContactNormal;
			ImpulseStrength = 300.0f;
		}
		else if (Hit.GetActor()->IsA<ASlime>())
		{
			bImpulseHit = true;
			ImpulseContactPoint = ContactPoint;
			ImpulseContactNormal = ContactNormal;
			ImpulseStrength = 150.0f;
		}
		
		/*
		 * 2. 각 파티클에 대해:
		 *    - 침투 검사
		 *        d = (p - cp) · n < 0
		 *
		 *    - PBD 위치 보정
		 *        correction = -d
		 *        p += w * correction * n
		 *
		 *    - 마찰 적용
		 *        move = p - p_prev
		 *        normal = (move · n) * n
		 *        tan = move - normal
		 *        move = normal + tan * (1 - friction)
		 *        p = p_prev + move
		 */
		for (FSlimeParticle& P : Particles)
		{
			FVector WorldPos = ActorTransform.TransformPosition(P.Position);
			float d = FVector::DotProduct(WorldPos - ContactPoint, ContactNormal);

			if (d < 0.0f)
			{
				float InvMass = 1.0f / P.Mass;
				
				float Correction = -d;
				WorldPos += InvMass * Correction * ContactNormal;
				
				FVector PrevWorldPos = ActorTransform.TransformPosition(P.PrevPosition);
				FVector Move = WorldPos - PrevWorldPos;

				FVector NormalMove = FVector::DotProduct(Move, ContactNormal) * ContactNormal;
				FVector TangentMove = Move - NormalMove;

				Move = NormalMove + TangentMove * (1.0f - Friction);

				WorldPos = PrevWorldPos + Move;

				P.Position = ActorTransform.InverseTransformPosition(WorldPos);
			}
		}
	}

	/*
	 * 임펄스 적용 (충돌한 액터가 플레이어나 다른 슬라임인 경우)
	 * - 충돌 지점으로부터의 거리 기반 가중치 계산
	 *	distance = |p - cp|
	 *	weight = clamp(1 - distance / radius, 0, 1)
	 *	
	 * - 가중치가 적용된 임펄스를 파티클에 추가
	 * impulse = n * strength * weight * dt
	 */
	if (bImpulseHit)
	{
		for (FSlimeParticle& P : Particles)
		{
			FVector WorldPos = ActorTransform.TransformPosition(P.Position);
			
			float Dist = FVector::Distance(WorldPos, ImpulseContactPoint);
			float Weight = FMath::Clamp(1.0f - Dist / SphereRadius, 0.0f, 1.0f);
			FVector Impulse = ImpulseContactNormal * ImpulseStrength * Weight * DeltaTime;

			WorldPos += Impulse; 
			
			P.Position = ActorTransform.InverseTransformPosition(WorldPos);
		}
	}
}

충돌 판정

각 충돌에 대해 모든 파티클을 검사한다.
충돌 판정은 다음 식으로 판단한다.

d=(pcp)nd = (p - cp) \cdot n
  • p : 파티클 월드 위치
  • cp : 충돌 지점
  • n : 충돌 법선
d<0d < 0

이면 파티클이 충돌면 안쪽에 들어간 상태이다.
이 경우 penetration을 해결해야 한다.

Collision Constraint Solve

전통적인 PBD 충돌 보정은 다음과 같이 동작한다.

Δx=C(x)wn\Delta x = -C(x) \cdot w \cdot n

여기서

  • C(x)C(x) : constraint 값 (침투 깊이)
  • ww : inverse mass
  • nn : 충돌 법선

현재 구현에서는

C(x)=dC(x) = d

이므로 다음과 같이 정리된다.

Δx=dwn\Delta x = -d \cdot w \cdot n

Position 기반 마찰 처리

충돌 보정 후에는 마찰을 적용한다.
현재 프레임 이동량은 다음과 같이 계산한다.

Move = CurrentPos - PrevPos

이를 법선 성분과 접선 성분으로 분해한다.

NormalMove = (Move · n) n
TangentMove = Move - NormalMove

접선 이동량에 마찰 계수를 적용한다.

Move = NormalMove + TangentMove * (1.0f - Friction);

충돌 액터에 따른 임펄스 적용

플레이어나 다른 슬라임과 충돌한 경우에는 추가적인 반응성을 주기 위해 임펄스를 적용한다.
임펄스 강도는 액터 타입에 따라 다르게 설정한다.

  • Pawn : 300
  • Slime : 150

가중치는 충돌 지점으로부터의 거리 기반으로 계산한다.

weight=clamp(1distanceradius,  0,  1)\text{weight} = \operatorname{clamp}\left(1 - \frac{\text{distance}}{\text{radius}}, \; 0, \; 1\right)

중심에 가까울수록 더 강한 힘을 받는다.

FVector Impulse = Normal * Strength * Weight * DeltaTime;
WorldPos += Impulse;

이는 SoftBody가 충격에 따라 국부적으로 찌그러지는 효과를 만든다.

3-2. Sphere Collision 위치 업데이트

슬라임은 파티클 기반 구조이므로, 실제 중심은 Actor 위치와 다를 수 있다.
따라서 매 프레임 파티클 중심을 계산한다.

그리고 Actor를 그 중심으로 이동시킨다.
이후 파티클은 로컬 위치를 유지하기 위해 중심을 빼준다.

Slime.h 수정

class XPBD_IMPLEMENT_API ASlime : public AActor
{
	GENERATED_BODY()
    
    ...
    
    FVector ComputeParticleCenter();

Slime.cpp 수정

FVector ASlime::ComputeParticleCenter()
{
	if (Particles.Num() == 0)
		return FVector::ZeroVector;

	FVector Sum = FVector::ZeroVector;

	for (const FSlimeParticle& P : Particles)
	{
		Sum += P.Position;
	}

	return Sum / Particles.Num();
}

void ASlime::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
	
	FVector Center = ComputeParticleCenter();
	
	// Actor를 중심 위치로 이동
	FVector ActorWorldPos = GetActorLocation();
	SetActorLocation(ActorWorldPos + Center);
	
	if (Particles.Num() == 0)
	{
		return;
	}
	
	// Particle들은 위치 유지 (로컬 위치 유지)
	for (FSlimeParticle& P : Particles)
	{
		P.Position -= Center;
	}
    
    ...
    
    // PBD 로직 스타트


4. Result

PBD 기반 SoftBody 슬라임의 충돌 처리와 마찰, 임펄스 반응이 정상적으로 동작하는 모습을 보여준다.

슬라임은 단일 리지드바디가 아니라 다수의 파티클로 구성된 구조이기 때문에, 충돌 시 전체가 단단하게 반응하지 않고 국소적으로 변형된다. 충돌면과 접촉한 파티클들만 법선 방향으로 보정되며, 이로 인해 슬라임 특유의 눌리는 느낌이 자연스럽게 표현되는 것을 볼 수 있다.

profile
언리얼 엔진 주니어(신입) 개발자 | 소설 쓰는 취준 개발자

0개의 댓글