커스텀 게임 물리 엔진의 오차와 한계: 어디까지 제어할 수 있는가

게임 물리 엔진을 직접 구현할 때 마주치는 수치 오차, 충돌 안정성, 결정성 문제를 실용적인 관점에서 정리한다. 완벽한 시뮬레이션보다 게임에 필요한 일관성과 디버깅 가능성을 설계하는 방법을 살펴본다.

✓ 복사 완료!
챕터게임 수학 & 물리
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
커스텀 게임 물리 엔진의 오차와 한계: 어디까지 제어할 수 있는가

물리 엔진을 직접 만든다는 의미

커스텀 물리 엔진은 중력과 속도를 더하는 코드에서 끝나지 않는다. 프레임 시간의 흔들림, 부동소수점 오차, 충돌체가 겹친 뒤의 보정, 여러 접촉점이 얽힌 상황까지 모두 게임의 조작감과 재현성에 영향을 준다.

특히 인디 프로젝트에서는 범용 엔진을 대체하려 하기보다 게임에 필요한 범위를 분명히 정하는 편이 현실적이다. 탑다운 액션이라면 2D 원형·사각형 충돌과 간단한 밀어내기로 충분할 수 있지만 차량과 경사면, 쌓이는 상자, 네트워크 동기화까지 지원하려면 문제의 성격이 완전히 달라진다.

가장 먼저 생기는 오차: 시간 적분

물체의 위치와 속도는 보통 가속도를 시간에 따라 누적해 갱신한다. 가장 단순한 전진 오일러 적분은 다음과 같다.

vt+Δt=vt+atΔtv_{t+\Delta t}=v_t+a_t\Delta t xt+Δt=xt+vtΔtx_{t+\Delta t}=x_t+v_t\Delta t

계산량은 적지만 프레임 간격이 커질수록 오차가 눈에 띄게 쌓인다. 중력 아래 낙하하는 물체가 예상보다 불안정하게 튀거나 스프링이 에너지를 얻어 계속 커지는 현상이 대표적이다.

게임에서는 반암시적 오일러 적분을 자주 사용한다. 먼저 속도를 갱신하고 갱신된 속도로 위치를 계산하는 방식이다.

void integrate(Body& body, float dt) {
    body.velocity += body.force * body.inverseMass * dt;
    body.position += body.velocity * dt;
    body.force = {0.0f, 0.0f};
}

이 방법도 정확한 해는 아니지만 같은 비용에서 전진 오일러보다 안정적인 경우가 많다. 중요한 점은 적분기를 바꾸는 것만으로 모든 문제가 해결되지는 않는다는 사실이다. 충돌 해결과 시간 관리가 함께 설계되어야 한다.

고정 시간 간격으로 진행되는 물리 시뮬레이션과 렌더링 프레임의 관계를 설명하는 한국어 다이어그램

가변 델타 타임의 함정

렌더링 프레임마다 물리를 한 번 갱신하면 dt가 매번 달라진다. 순간적인 프레임 저하가 큰 이동량과 깊은 충돌을 만들고 그 결과 충돌 보정도 과격해진다. 이를 줄이려면 물리는 고정 시간 간격으로 실행하고 렌더링만 가변 프레임으로 처리하는 구조가 유용하다.

constexpr float fixedDt = 1.0f / 60.0f;
float accumulator = 0.0f;

void update(float frameDt) {
    accumulator += std::min(frameDt, 0.25f);

    while (accumulator >= fixedDt) {
        physicsStep(fixedDt);
        accumulator -= fixedDt;
    }

    render(accumulator / fixedDt);
}

여기서 frameDt 상한은 긴 멈춤 뒤에 수백 번의 물리 스텝을 한 프레임에 처리하는 상황을 완화한다. 다만 상한을 넘긴 시간은 시뮬레이션에서 버려진다. 정확도와 응답성, 처리량 사이의 의도적인 선택이다.

충돌은 판정보다 해결이 어렵다

두 도형이 겹쳤는지 찾는 일과 겹친 상태를 자연스럽게 풀어내는 일은 다르다. AABB나 원 충돌 판정은 비교적 단순하지만 충돌 후 속도와 위치를 어떻게 바꿀지는 질량, 반발 계수, 마찰, 접촉 법선, 관통 깊이에 따라 달라진다.

두 물체가 한 프레임 안에 깊게 겹친 경우 위치만 즉시 밀어내면 눈에 보이는 겹침은 사라진다. 하지만 다음 프레임에 다시 충돌하거나 바닥 위 물체가 미세하게 흔들리는 문제가 생길 수 있다. 반대로 충격량만 적용하면 이미 생긴 관통이 오래 남을 수 있다.

관통 보정은 완벽한 해법이 아니다

실무에서는 관통 깊이 전체를 한 번에 보정하지 않고 일부만 보정하며 작은 허용 오차를 둔다.

// 전제: normal은 길이가 1인 충돌 법선이며 penetration은 양수일 때 겹침을 뜻한다.
void positionalCorrection(Body& a, Body& b, Vec2 normal, float penetration) {
    constexpr float slop = 0.01f;
    constexpr float percent = 0.8f;

    float invMassSum = a.inverseMass + b.inverseMass;
    if (invMassSum == 0.0f) return;

    float correctionMagnitude =
        std::max(penetration - slop, 0.0f) * percent / invMassSum;
    Vec2 correction = normal * correctionMagnitude;

    a.position -= correction * a.inverseMass;
    b.position += correction * b.inverseMass;
}

slop은 눈에 보이지 않는 수준의 작은 관통을 허용해 계속된 보정을 줄인다. percent는 한 스텝에서 얼마나 강하게 분리할지 정한다. 값이 너무 크면 튀는 느낌이 강해지고 너무 작으면 겹침이 오래 남는다. 이 값들은 물리적으로 절대적인 상수가 아니라 게임의 스케일과 고정 시간 간격에 맞춰 조정해야 하는 파라미터다.

이산 충돌 검사의 한계

일반적인 물리 스텝은 현재 위치에서 충돌을 검사한다. 빠른 총알이 얇은 벽 앞에 있다가 다음 프레임에는 벽 너머에 도착했다면 어느 시점에도 겹친 상태가 없어서 충돌을 놓칠 수 있다. 이를 터널링이라고 한다.

해결책은 크게 세 가지다.

  • 물리 스텝을 더 짧게 만들거나 빠른 물체만 여러 번 나누어 갱신한다.
  • 이동 경로를 선분 또는 스윕 도형으로 검사해 충돌 시점을 구한다.
  • 총알처럼 규칙이 단순한 객체는 물리 강체 대신 레이캐스트 기반 판정을 사용한다.

연속 충돌 검사는 빠른 물체에 효과적이지만 구현과 계산 비용이 커진다. 모든 동적 물체에 적용하기보다 속도가 빠르고 충돌 누락이 게임 규칙을 깨뜨리는 객체에만 적용하는 것이 보통 더 낫다.

반복 풀이와 접촉 불안정성

여러 물체가 동시에 닿으면 한 번의 충돌 해결으로는 조건을 모두 만족시키기 어렵다. 바닥 위 상자 더미를 생각해 보면 아래 상자를 보정한 결과가 위 상자의 접촉 조건을 다시 바꾼다. 그래서 많은 실시간 엔진은 충돌 제약을 여러 번 반복해 근사적으로 푼다.

반복 횟수를 늘리면 안정성은 좋아지지만 성능 비용도 증가한다. 또한 순서에 따라 결과가 조금씩 달라질 수 있다. 다음과 같은 흐름으로 문제를 분리해 두면 조정 지점을 찾기 쉽다.

flowchart TD
    A[고정 시간 물리 스텝] --> B[넓은 단계 후보 쌍 생성]
    B --> C[정밀 충돌 판정]
    C --> D[접촉 정보 생성]
    D --> E[충격량 제약 반복 풀이]
    E --> F[관통 위치 보정]
    F --> G[수면 상태와 이벤트 갱신]

반복 횟수는 장면 전체에 하나의 고정값으로 적용할 수도 있지만 게임의 우선순위에 따라 달리 둘 수 있다. 예를 들어 플레이어 발밑 접촉은 더 안정적으로 처리하고 멀리 있는 장식용 오브젝트는 낮은 정밀도를 허용하는 식이다.

부동소수점과 결정성의 경계

일반적인 부동소수점 시뮬레이션은 같은 입력만으로 모든 플랫폼에서 비트 단위로 같은 결과를 보장하지 않는다. 연산 순서와 구현 차이가 누적 결과에 영향을 줄 수 있다. 다만 결정성을 의도적으로 설계한 엔진과 특정 버전은 플랫폼 간 재현성을 제공할 수 있으므로, 목표 엔진의 공식 보장 범위를 확인해야 한다.

싱글플레이 게임이라면 아주 작은 차이는 대개 문제가 되지 않는다. 그러나 롤백 네트코드나 결정론적 리플레이를 목표로 한다면 문제가 달라진다. 이 경우에는 다음 원칙을 검토할 만하다.

  • 고정 시간 간격과 입력 단위를 명확히 고정한다.
  • 충돌 후보와 제약을 일관된 키로 정렬한다.
  • 난수는 시드와 호출 순서를 관리한다.
  • 상태 해시를 기록해 첫 불일치 지점을 찾는다.
  • 필요한 범위에서만 고정소수점 또는 정수 기반 연산을 고려한다.

고정소수점은 결정성에 도움이 될 수 있지만 범위와 정밀도를 직접 관리해야 한다. 큰 월드 좌표와 미세한 속도를 동시에 표현하려면 오버플로와 양자화 오차를 검토해야 하므로 무조건적인 대체재는 아니다.

월드 스케일과 좌표 정밀도

float로 넓은 월드를 그대로 표현하면 원점에서 멀어질수록 인접한 표현 가능 값 사이의 간격이 커진다. 멀리 있는 위치에서 작은 이동이나 접촉 보정이 뭉개져 카메라 흔들림, 충돌 떨림으로 나타날 수 있다.

이를 피하는 대표적인 방법은 월드 원점을 주기적으로 이동하는 플로팅 오리진이다. 플레이어 근처를 새 원점으로 삼고 물리 객체와 렌더 객체의 좌표를 함께 옮긴다. 다만 저장 데이터, 파티클, 내비게이션, 네트워크 좌표가 같은 기준을 공유하는지 반드시 점검해야 한다.

구현 범위를 정하는 체크리스트

커스텀 엔진의 품질은 기능 목록의 길이보다 실패 조건을 얼마나 명확히 관리하는지에 달려 있다.

  • 지원하는 도형을 최소화한다. 원과 축 정렬 사각형만으로도 많은 2D 게임을 만들 수 있다.
  • 정적 지형과 동적 강체를 구분한다. 모든 것을 동적 강체로 풀 필요는 없다.
  • 최대 속도와 최소 충돌체 두께를 문서화한다.
  • 프레임 저하와 큰 좌표, 다중 접촉을 재현하는 테스트 장면을 만든다.
  • 접촉 법선, 관통 깊이, 충격량을 화면에 표시하는 디버그 뷰를 준비한다.

마무리

커스텀 물리 엔진에서 오차는 제거 대상이라기보다 관리 대상이다. 적분 오차와 관통, 연속 충돌의 비용, 부동소수점 차이는 서로 연결되어 있으며 하나의 기법만으로 해결되지 않는다.

먼저 게임이 반드시 지켜야 할 규칙을 정하고 그 규칙을 깨뜨리는 상황에만 정밀도와 복잡성을 투자하자. 플레이어가 체감하는 안정성, 재현 가능한 버그 보고 충분한 디버그 정보가 갖춰졌다면 물리 엔진은 이미 게임에 필요한 역할을 하고 있는 셈이다.