게임 물리엔진 커스텀: 충돌 판정 Raycast 처리 노하우

게임 물리엔진 커스텀: 충돌 판정 Raycast 처리 노하우

Raycast를 직접 구현하거나 엔진 API 위에서 안정적으로 사용할 때 필요한 교차 판정, 수치 오차 대응, 필터링과 후처리 원칙을 정리한다.

Raycast는 무엇을 반환해야 하는가

Raycast는 시작점에서 특정 방향으로 뻗은 반직선이 충돌체와 처음 만나는 지점을 찾는 질의다. 단순히 맞았다/맞지 않았다만 반환하면 곧 한계가 온다. 게임플레이 코드는 보통 거리, 충돌 지점, 법선, 충돌체 식별자까지 필요로 한다.

가장 실용적인 결과 구조는 다음과 같다.

struct RaycastHit
{
    bool hit = false;
    float distance = 0.0f;
    Vec3 point{};
    Vec3 normal{};
    ColliderId colliderId{};
};

방향 벡터는 호출 지점에서 정규화하는 편이 안전하다. 정규화하지 않으면 t가 월드 거리와 일치하지 않아 최대 사거리 비교와 결과 해석이 복잡해진다.

P(t)=O+tD,0ttmaxP(t)=O+tD,\quad 0\leq t\leq t_{max}

여기서 O는 시작점, D는 정규화된 방향, tMax는 최대 거리다. D가 단위 벡터라면 t 자체가 거리다.

광선의 시작점과 방향, 최대 거리, 충돌 지점 및 법선을 표시한 Raycast 개념도

먼저 Broad Phase로 후보를 줄인다

월드의 모든 삼각형이나 콜라이더에 광선을 검사하면 Raycast 하나는 단순해도 호출 횟수가 많아질 때 비용이 커진다. 특히 AI 시야, 총알, 지면 확인, 카메라 충돌을 매 프레임 함께 수행하면 병목이 되기 쉽다.

일반적인 순서는 다음과 같다.

flowchart TD
    A[Ray 입력: 원점, 방향, 최대 거리] --> B[레이어와 마스크로 제외]
    B --> C[Broad Phase: BVH, 그리드, AABB 후보]
    C --> D[Narrow Phase: 정확한 도형 교차]
    D --> E[가장 가까운 유효 Hit 선택]
    E --> F[거리, 지점, 법선 보정 후 반환]

Broad Phase에서는 광선과 AABB의 교차 여부만으로 후보를 거른다. 공간 해시나 균일 그리드는 월드가 비교적 균일할 때 구현이 간단하다. 정적 메시가 많다면 BVH가 더 적합하다. 동적 물체는 별도 트리나 프록시 AABB로 관리하면 갱신 비용을 줄일 수 있다.

중요한 점은 후보 정렬을 너무 일찍 강제하지 않는 것이다. 후보 AABB의 진입 거리로 현재 최단 거리보다 먼 노드를 건너뛰면 되므로 BVH 순회 중 bestDistance를 계속 줄여 나갈 수 있다.

광선과 기본 도형의 교차

구체

구 중심을 C, 반지름을 r이라고 하자. 광선 위의 점과 중심 사이 거리가 반지름이 되는 t를 구하면 된다. 구현에서는 판별식이 거의 0일 때 부동소수점 오차를 고려해야 한다.

bool RaySphere(const Vec3& origin, const Vec3& dir, float maxDistance,
               const Vec3& center, float radius, RaycastHit& outHit)
{
    const Vec3 oc = origin - center;
    const float b = Dot(oc, dir);
    const float c = Dot(oc, oc) - radius * radius;
    const float discriminant = b * b - c;

    if (discriminant < 0.0f)
        return false;

    const float root = std::sqrt(discriminant);
    float t = -b - root;
    if (t < 0.0f)
        t = -b + root;

    if (t < 0.0f || t > maxDistance)
        return false;

    outHit.hit = true;
    outHit.distance = t;
    outHit.point = origin + dir * t;
    outHit.normal = Normalize(outHit.point - center);
    return true;
}

시작점이 구 안에 있는 경우 첫 번째 해는 음수가 된다. 위 구현처럼 두 번째 해를 검사하면 광선이 구 밖으로 나가는 지점을 얻는다. 반대로 내부 시작을 충돌로 취급하지 않는 규칙도 가능하므로 사용처의 의도를 API 계약으로 명확히 정해야 한다.

AABB

AABB에는 slab 방식이 널리 쓰인다. 각 축에서 박스에 진입하고 이탈하는 t 구간을 구한 뒤 세 축 구간의 공통 부분이 있는지 확인한다.

bool RayAabb(const Vec3& origin, const Vec3& dir, float maxDistance,
             const Vec3& min, const Vec3& max, float& outT)
{
    const Vec3 invDir = {
        1.0f / dir.x,
        1.0f / dir.y,
        1.0f / dir.z
    };

    const Vec3 t0 = (min - origin) * invDir;
    const Vec3 t1 = (max - origin) * invDir;

    const Vec3 tMin3 = Min(t0, t1);
    const Vec3 tMax3 = Max(t0, t1);

    const float tEnter = Max(tMin3.x, Max(tMin3.y, tMin3.z));
    const float tExit = Min(tMax3.x, Min(tMax3.y, tMax3.z));

    if (tEnter > tExit || tExit < 0.0f)
        return false;

    outT = (tEnter >= 0.0f) ? tEnter : tExit;
    return outT <= maxDistance;
}

실제 코드에서는 방향 성분이 0에 가까운 경우를 별도로 처리해야 한다. 무한대에 의존하는 구현도 가능하지만 플랫폼과 벡터 라이브러리의 동작을 확인하지 않았다면 축별 분기로 명시적으로 처리하는 편이 디버깅하기 쉽다.

수치 오차와 자기 충돌을 다루는 법

Raycast 오류의 상당수는 교차 공식 자체보다 시작점과 경계가 너무 가까운 상황에서 발생한다. 표면에서 반사 광선을 쏘거나 투사체가 방금 맞은 콜라이더를 다시 검사할 때 대표적으로 나타난다.

가장 흔한 대응은 법선 방향으로 아주 작은 거리를 이동한 곳에서 시작하는 것이다.

constexpr float kRayEpsilon = 1e-4f;

Vec3 nextOrigin = hit.point + hit.normal * kRayEpsilon;
RaycastHit nextHit = world.Raycast(nextOrigin, reflectedDir, remainingDistance);

이 값은 월드 단위와 콘텐츠 규모에 맞춰 정해야 한다. 너무 작으면 자기 충돌이 남고 너무 크면 얇은 벽이나 좁은 틈을 건너뛴다. 단일 상수만 고집하기보다 콜라이더 크기, 좌표 크기, 게임의 최소 지오메트리 두께를 기준으로 테스트하는 편이 낫다.

또한 t == 0을 무조건 무시하면 캐릭터가 벽에 끼었는지 확인하는 용도에서 중요한 접촉을 놓칠 수 있다. 질의 목적에 따라 tMin을 인자로 받아 명시적으로 제어하면 재사용성이 좋아진다.

필터링은 충돌 판정보다 먼저 설계한다

Raycast가 무엇을 맞출 수 있는지는 게임 규칙이다. 총알이 트리거를 무시하는지 AI 시야가 투명 창문을 통과하는지 카메라가 플레이어 자신의 콜라이더를 제외하는지는 모두 서로 다르다.

따라서 레이어 마스크와 제외 목록을 Raycast 입력으로 둔다.

struct RaycastQuery
{
    Vec3 origin;
    Vec3 direction;
    float maxDistance;
    uint32_t layerMask;
    ColliderId ignoredCollider;
    bool includeTriggers;
};

Narrow Phase를 수행한 뒤 결과를 버리는 것보다 후보 수집 단계에서 레이어와 활성 상태를 걸러야 한다. 자주 쓰는 조합은 이름 있는 마스크로 관리하면 호출부의 의미도 분명해진다. 예를 들어 kMaskVisionBlocker, kMaskProjectile, kMaskCameraObstacle처럼 구분할 수 있다.

가장 가까운 Hit와 정렬 규칙

단일 Hit Raycast는 유효한 결과 중 거리가 가장 짧은 것을 반환해야 한다. 다만 두 충돌 지점이 거의 같은 거리라면 프레임마다 다른 결과가 나와 네트워크 동기화나 리플레이에서 문제가 될 수 있다.

동일 거리 판정 범위를 정하고 그 안에서는 안정적인 우선순위를 둔다. 예를 들어 콜라이더 ID가 작은 쪽을 선택할 수 있다. 다중 Hit가 필요한 경우에는 거리순 정렬뿐 아니라 중복 콜라이더 처리, 트리거 포함 여부, 관통 가능한 재질의 후속 처리까지 호출 규약에 포함해야 한다.

if (!best.hit || candidate.distance < best.distance - kRayEpsilon ||
    (std::abs(candidate.distance - best.distance) <= kRayEpsilon &&
     candidate.colliderId < best.colliderId))
{
    best = candidate;
}

디버깅과 테스트 체크리스트

Raycast는 화면에 보이지 않기 때문에 디버그 드로잉이 특히 중요하다. 광선은 최대 거리까지 선으로 그리고 Hit 지점은 점으로 법선은 다른 색의 짧은 선으로 표시한다. 이때 게임에서 사용하는 실제 필터와 최대 거리로 그려야 문제를 재현할 수 있다.

다음 경우는 자동 테스트 또는 재현용 테스트 맵에 포함하는 것이 좋다.

  • 시작점이 콜라이더 밖, 표면 위, 내부에 있는 경우
  • 방향 성분 중 하나 이상이 0인 경우
  • 최대 거리 바로 안쪽과 바깥쪽의 충돌
  • 두 콜라이더가 같은 거리에서 만나는 경우
  • 매우 얇은 콜라이더와 큰 월드 좌표
  • 트리거, 비활성 콜라이더, 제외 레이어가 섞인 경우

마무리

좋은 Raycast 구현은 정확한 교차 수식 하나로 완성되지 않는다. 후보를 빠르게 줄이는 구조, 호출 목적에 맞는 필터, 부동소수점 경계 처리, 일관된 가장 가까운 Hit 선택 규칙이 함께 있어야 한다. 먼저 구체와 AABB처럼 검증하기 쉬운 도형으로 결과 계약을 고정한 뒤 BVH와 메시 삼각형 교차를 추가하는 순서가 구현과 디버깅 모두에 유리하다.

#game-physics#raycast#collision#c-plus-plus#unity

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs